This commit is contained in:
ZOMBIIIIIII
2026-05-13 12:07:48 +03:00
parent 3a890b79ee
commit 762a46871b
10 changed files with 375 additions and 155 deletions

View File

@@ -53,6 +53,12 @@ export let env = {
allowCredentials: p.CORS_ALLOW_CREDENTIALS === 'true',
},
port: parseInt(p.API_PORT || '3001'),
redis: {
host: p.REDIS_HOST || 'keydb',
port: parseInt(p.REDIS_PORT || '6379'),
password: p.REDIS_PASSWORD || '',
db: parseInt(p.REDIS_DB || '0'),
},
relayApiKey: p.RELAY_API_KEY || null,
tronApiKey: p.TRON_API_KEY || null,
jupiterApiKey: p.JUPITER_API_KEY || null,

View File

@@ -0,0 +1,86 @@
/**
* KeyDB / Redis singleton client.
*
* Используется для idempotency cache (см. `lib/idempotency.ts`).
*
* Connection:
* REDIS_HOST=keydb (docker service name) / REDIS_PORT=6379 / REDIS_PASSWORD / REDIS_DB=0
*
* Startup contract: `pingRedis()` вызывается из `index.ts` и throws если KeyDB
* unreachable — fail-fast, потому что idempotency critical для money flow.
*/
import Redis, { type RedisOptions } from 'ioredis';
import { logger } from '../lib/logger';
let _client: Redis | null = null;
function buildClient(): Redis {
const host = process.env.REDIS_HOST || 'keydb';
const port = parseInt(process.env.REDIS_PORT || '6379', 10);
const password = process.env.REDIS_PASSWORD || '';
const db = parseInt(process.env.REDIS_DB || '0', 10);
if (!Number.isFinite(port) || port < 1 || port > 65535) {
throw new Error(`Invalid REDIS_PORT ${process.env.REDIS_PORT}`);
}
if (!Number.isFinite(db) || db < 0 || db > 15) {
throw new Error(`Invalid REDIS_DB ${process.env.REDIS_DB} (must be 0-15)`);
}
const opts: RedisOptions = {
host,
port,
db,
lazyConnect: true,
// Не зависать forever — fail-fast если cache недоступен
connectTimeout: 5000,
maxRetriesPerRequest: 3,
// Reconnect strategy: exponential backoff, max 5s
retryStrategy: (times) => Math.min(times * 200, 5000),
};
if (password) opts.password = password;
const client = new Redis(opts);
client.on('error', (err) => {
// Не логируем secret в случае конфигурационной ошибки
logger.error(`Redis client error: ${err.message}`);
});
client.on('connect', () => logger.info(`Redis connected (host=${host}:${port} db=${db})`));
client.on('reconnecting', (delay: number) => logger.warn(`Redis reconnecting in ${delay}ms`));
return client;
}
/** Lazily initialised singleton. */
export function getRedis(): Redis {
if (!_client) {
_client = buildClient();
}
return _client;
}
/**
* Startup ping. Throws on failure → caller process.exit(1).
* Connect-on-demand (lazyConnect=true), .ping() триггерит connect + первый round-trip.
*/
export async function pingRedis(): Promise<void> {
const client = getRedis();
try {
const pong = await client.ping();
if (pong !== 'PONG') {
throw new Error(`Redis PING returned ${pong} (expected PONG)`);
}
} catch (err: any) {
throw new Error(`Redis ping failed: ${err.message}`);
}
}
/** Graceful shutdown — closes connection cleanly. */
export async function closeRedis(): Promise<void> {
if (_client) {
await _client.quit().catch(() => _client?.disconnect());
_client = null;
}
}