Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/relay/src/lib/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export * from './ethService/contractService/IContractService';
export * from './ethService/transactionService/TransactionService';
export * from '../types/rateLimiter';
export * from './rateLimiterService/LruRateLimitStore';
export * from './rateLimiterService/RateLimitStoreFactory';
export * from './rateLimiterService/RedisRateLimitStore';
export * from './rateLimiterService/rateLimiterService';
export * from './transactionPoolService/LocalPendingTransactionStorage';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: Apache-2.0

import { Logger } from 'pino';
import { Counter } from 'prom-client';
import { RedisClientType } from 'redis';

import { RateLimitStore } from '../../types';
import { LruRateLimitStore } from './LruRateLimitStore';
import { RedisRateLimitStore } from './RedisRateLimitStore';

/**
* Factory for creating RateLimitStore instances.
*
* Encapsulates the logic for selecting the appropriate storage implementation
* based on available infrastructure (Redis vs in-memory).
*/
export class RateLimitStoreFactory {
/**
* Creates a RateLimitStore instance.
*
* @param logger - Logger instance for the store.
* @param duration - Time window in milliseconds for rate limiting.
* @param rateLimitStoreFailureCounter - Optional counter for tracking store failures.
* @param redisClient - Optional Redis client. If provided, creates Redis-backed storage;
* otherwise creates local in-memory storage.
* @returns A RateLimitStore implementation.
*/
static create(
logger: Logger,
duration: number,
rateLimitStoreFailureCounter?: Counter,
redisClient?: RedisClientType,
): RateLimitStore {
return redisClient
? new RedisRateLimitStore(redisClient, logger, duration, rateLimitStoreFailureCounter)
: new LruRateLimitStore(duration);
}
}
Original file line number Diff line number Diff line change
@@ -1,22 +1,18 @@
// SPDX-License-Identifier: Apache-2.0

import { ConfigService } from '@hashgraph/json-rpc-config-service/dist/services';
import { Logger } from 'pino';
import { Counter } from 'prom-client';
import { createClient, RedisClientType } from 'redis';
import { RedisClientType } from 'redis';

import { RedisCacheError } from '../../errors/RedisCacheError';
import { RateLimitKey, RateLimitStore } from '../../types';

/**
* Redis-based rate limit store implementation using Lua scripting for atomic operations.
* Implements both RateLimitStore for core functionality
* Implements RateLimitStore for core functionality.
*/
export class RedisRateLimitStore implements RateLimitStore {
private redisClient: RedisClientType;
private logger: Logger;
private connected: Promise<boolean>;
private rateLimitStoreFailureCounter?: Counter;
private readonly logger: Logger;
private readonly rateLimitStoreFailureCounter?: Counter;
private readonly duration: number;

/**
Expand Down Expand Up @@ -46,66 +42,23 @@ export class RedisRateLimitStore implements RateLimitStore {
return 0
`;

constructor(logger: Logger, duration: number, rateLimitStoreFailureCounter?: Counter) {
/**
* Creates a Redis-backed rate limit store.
*
* @param redisClient - A connected Redis client instance.
* @param logger - Logger instance for logging.
* @param duration - Time window in milliseconds for rate limiting.
* @param rateLimitStoreFailureCounter - Optional counter for tracking store failures.
*/
constructor(
private readonly redisClient: RedisClientType,
logger: Logger,
duration: number,
rateLimitStoreFailureCounter?: Counter,
) {
this.logger = logger.child({ name: 'redis-rate-limit-store' });
this.duration = duration;
this.rateLimitStoreFailureCounter = rateLimitStoreFailureCounter;

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amaziing, much cleaner!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great glad you find it better!

const redisUrl = ConfigService.get('REDIS_URL')!;
const reconnectDelay = ConfigService.get('REDIS_RECONNECT_DELAY_MS');

this.redisClient = createClient({
url: redisUrl,
socket: {
reconnectStrategy: (retries: number) => {
const delay = retries * reconnectDelay;
this.logger.warn(`Rate limiter Redis reconnection attempt #${retries}. Delay: ${delay}ms`);
return delay;
},
},
});

this.connected = this.redisClient
.connect()
.then(() => true)
.catch((error) => {
this.logger.error(error, 'Rate limiter Redis connection could not be established!');
return false;
});

this.redisClient.on('ready', () => {
this.connected = Promise.resolve(true);
this.logger.info(`Rate limiter connected to Redis server successfully!`);
});

this.redisClient.on('end', () => {
this.connected = Promise.resolve(false);
this.logger.info('Rate limiter disconnected from Redis server!');
});

this.redisClient.on('error', (error) => {
this.connected = Promise.resolve(false);
const redisError = new RedisCacheError(error);
if (redisError.isSocketClosed()) {
this.logger.error(`Rate limiter Redis error when closing socket: ${redisError.message}`);
} else {
this.logger.error(`Rate limiter Redis error: ${redisError.fullError}`);
}
});
}

/**
* Ensures the Redis client is connected before use.
* @private
* @returns Connected Redis client instance.
* @throws Error if the Redis client is not connected.
*/
private async getConnectedClient(): Promise<RedisClientType> {
const isConnected = await this.connected;
if (!isConnected) {
throw new Error('Redis client is not connected');
}
return this.redisClient;
}

/**
Expand All @@ -116,9 +69,8 @@ export class RedisRateLimitStore implements RateLimitStore {
*/
async incrementAndCheck(key: RateLimitKey, limit: number): Promise<boolean> {
try {
const client = await this.getConnectedClient();
const durationSeconds = Math.ceil(this.duration / 1000);
const result = await client.eval(RedisRateLimitStore.LUA_SCRIPT, {
const result = await this.redisClient.eval(RedisRateLimitStore.LUA_SCRIPT, {
keys: [key.toString()],
arguments: [String(limit), String(durationSeconds)],
});
Expand All @@ -138,24 +90,4 @@ export class RedisRateLimitStore implements RateLimitStore {
return false;
}
}

/**
* Checks if the Redis client is connected.
*/
async isConnected(): Promise<boolean> {
return this.connected;
}

/**
* Disconnects from Redis.
*/
async disconnect(): Promise<void> {
try {
if (await this.isConnected()) {
await this.redisClient.quit();
}
} catch (error) {
this.logger.error(error, 'Error disconnecting from Redis');
}
}
}
Original file line number Diff line number Diff line change
@@ -1,25 +1,27 @@
// SPDX-License-Identifier: Apache-2.0

import { ConfigService } from '@hashgraph/json-rpc-config-service/dist/services';
import { Logger } from 'pino';
import { Counter, Registry } from 'prom-client';

import { RateLimitKey, RateLimitStore, RateLimitStoreType } from '../../types';
import { RateLimitKey, RateLimitStore } from '../../types';
import { RequestDetails } from '../../types/RequestDetails';
import { LruRateLimitStore } from './LruRateLimitStore';
import { RedisRateLimitStore } from './RedisRateLimitStore';

/**
* Service to apply IP and method-based rate limiting using configurable stores.
*/
export class IPRateLimiterService {
private store: RateLimitStore;
private logger: Logger;
private ipRateLimitCounter: Counter;
private rateLimitStoreFailureCounter: Counter;

constructor(logger: Logger, register: Registry, duration: number) {
this.logger = logger;
/**
* Creates an IPRateLimiterService instance.
*
* @param store - The rate limit storage backend (LRU or Redis-backed).
* @param logger - Logger instance for logging.
* @param register - Prometheus registry for metrics.
*/
constructor(store: RateLimitStore, register: Registry) {
this.store = store;

// Initialize IP rate limit counter
const ipRateLimitMetricName = 'rpc_relay_ip_rate_limit';
Expand All @@ -32,68 +34,6 @@ export class IPRateLimiterService {
labelNames: ['methodName', 'storeType'],
registers: [register],
});

// Initialize store failure counter
const storeFailureMetricName = 'rpc_relay_rate_limit_store_failures';
if (register.getSingleMetric(storeFailureMetricName)) {
register.removeSingleMetric(storeFailureMetricName);
}
this.rateLimitStoreFailureCounter = new Counter({
name: storeFailureMetricName,
help: 'Rate limit store failure counter',
labelNames: ['storeType', 'operation'],
registers: [register],
});

const storeType = this.determineStoreType();
this.store = this.createStore(storeType, duration);
}

/**
* Determines which rate limit store type to use based on configuration.
* Fails fast if an invalid store type is explicitly configured.
* @private
* @returns Store type identifier.
* @throws Error if an invalid store type is explicitly configured.
*/
private determineStoreType(): RateLimitStoreType {
const configuredStoreType = ConfigService.get('IP_RATE_LIMIT_STORE');

// If explicitly configured, validate it
if (configuredStoreType !== null) {
const normalizedType = String(configuredStoreType).trim().toUpperCase() as RateLimitStoreType;

if (Object.values(RateLimitStoreType).includes(normalizedType)) {
this.logger.info(`Using configured rate limit store type: ${normalizedType}`);
return normalizedType;
}

// Fail fast for invalid configurations
throw new Error(
`Unsupported IP_RATE_LIMIT_STORE value: "${configuredStoreType}". ` +
`Supported values are: ${Object.values(RateLimitStoreType).join(', ')}`,
);
}

// Only fall back to REDIS_ENABLED if IP_RATE_LIMIT_STORE is not set
const fallbackType = ConfigService.get('REDIS_ENABLED') ? RateLimitStoreType.REDIS : RateLimitStoreType.LRU;
this.logger.info(`IP_RATE_LIMIT_STORE not configured, using fallback based on REDIS_ENABLED: ${fallbackType}`);
return fallbackType;
}

/**
* Creates an appropriate rate limit store instance based on the specified type.
*/
private createStore(storeType: RateLimitStoreType, duration: number): RateLimitStore {
switch (storeType) {
case RateLimitStoreType.REDIS:
return new RedisRateLimitStore(this.logger, duration, this.rateLimitStoreFailureCounter);
case RateLimitStoreType.LRU:
return new LruRateLimitStore(duration);
default:
// This should never happen due to enum typing, but including for completeness
throw new Error(`Unsupported store type: ${storeType}`);
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: Apache-2.0

import chai, { expect } from 'chai';
import chaiAsPromised from 'chai-as-promised';
import { Logger, pino } from 'pino';
import { Counter, Registry } from 'prom-client';

import { LruRateLimitStore } from '../../../../src/lib/services/rateLimiterService/LruRateLimitStore';
import { RateLimitStoreFactory } from '../../../../src/lib/services/rateLimiterService/RateLimitStoreFactory';
import { RedisRateLimitStore } from '../../../../src/lib/services/rateLimiterService/RedisRateLimitStore';

chai.use(chaiAsPromised);

describe('RateLimitStoreFactory', () => {
let logger: Logger;
let registry: Registry;
let rateLimitStoreFailureCounter: Counter;
const testDuration = 5000;
const mockRedisClient = { eval: () => {} } as any;

beforeEach(() => {
logger = pino({ level: 'silent' });
registry = new Registry();
rateLimitStoreFailureCounter = new Counter({
name: 'test_rate_limit_store_failure',
help: 'Test counter for rate limit store failures',
labelNames: ['store_type', 'method'],
registers: [registry],
});
});

describe('create', () => {
it('should return LruRateLimitStore when redisClient is undefined', () => {
const store = RateLimitStoreFactory.create(logger, testDuration, rateLimitStoreFailureCounter, undefined);
expect(store).to.be.instanceOf(LruRateLimitStore);
});

it('should return RedisRateLimitStore when redisClient is provided', () => {
const store = RateLimitStoreFactory.create(logger, testDuration, rateLimitStoreFailureCounter, mockRedisClient);

expect(store).to.be.instanceOf(RedisRateLimitStore);
});

it('should return LruRateLimitStore without failure counter', () => {
const store = RateLimitStoreFactory.create(logger, testDuration);

expect(store).to.be.instanceOf(LruRateLimitStore);
});

it('should return RedisRateLimitStore with failure counter', () => {
const store = RateLimitStoreFactory.create(logger, testDuration, rateLimitStoreFailureCounter, mockRedisClient);

expect(store).to.be.instanceOf(RedisRateLimitStore);
});

it('should create different store instances on multiple calls', () => {
const store1 = RateLimitStoreFactory.create(logger, testDuration);
const store2 = RateLimitStoreFactory.create(logger, testDuration, rateLimitStoreFailureCounter, mockRedisClient);

expect(store1).to.not.equal(store2);
expect(store1).to.be.instanceOf(LruRateLimitStore);
expect(store2).to.be.instanceOf(RedisRateLimitStore);
});
});
});
Loading
Loading