Rate limiting: - Replace in-memory express-rate-limit with ioredis + rate-limiter-flexible (sliding window) - Graceful fallback to RateLimiterMemory when Redis unreachable - RATE_LIMIT_WINDOW_MS / RATE_LIMIT_MAX_REQUESTS env var config - Retry-After header on 429 responses - agentidp_rate_limit_hits_total Prometheus counter Database pool: - Explicit pg.Pool config via DB_POOL_MAX/MIN/IDLE_TIMEOUT_MS/CONNECTION_TIMEOUT_MS - Defaults: max=20, min=2, idle=30s, conn timeout=5s - agentidp_db_pool_active_connections + agentidp_db_pool_waiting_requests gauges Health endpoint: - GET /health/detailed — per-service status (database, Redis, Vault, OPA) - healthy / degraded (>1000ms) / unreachable classification - HTTP 200 (all healthy) / 207 (any degraded) / 503 (any unreachable) Load tests: - tests/load/ with k6 scenarios for agent registration (100 VUs), token issuance (1000 VUs), credential rotation (50 VUs) - npm run load-test script Tests: 586 passing, zero TypeScript errors Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
115 lines
3.6 KiB
TypeScript
115 lines
3.6 KiB
TypeScript
/**
|
|
* PostgreSQL connection pool singleton.
|
|
* All database access flows through this pool.
|
|
*
|
|
* Pool configuration env vars (task 2.1 / 2.2):
|
|
* DB_POOL_MAX — maximum connections (default 20)
|
|
* DB_POOL_MIN — minimum connections (default 2)
|
|
* DB_POOL_IDLE_TIMEOUT_MS — idle connection timeout in ms (default 30000)
|
|
* DB_POOL_CONNECTION_TIMEOUT_MS — connection acquisition timeout in ms (default 5000)
|
|
*/
|
|
|
|
import { Pool } from 'pg';
|
|
import {
|
|
dbQueryDurationSeconds,
|
|
dbPoolActiveConnections,
|
|
dbPoolWaitingRequests,
|
|
} from '../metrics/registry.js';
|
|
|
|
let pool: Pool | null = null;
|
|
|
|
/**
|
|
* Returns the singleton pg Pool instance.
|
|
* Initialises the pool on first call using DATABASE_URL and optional pool
|
|
* tuning env vars.
|
|
*
|
|
* Prometheus gauges `agentidp_db_pool_active_connections` and
|
|
* `agentidp_db_pool_waiting_requests` are updated via pool events (task 2.3).
|
|
*
|
|
* @returns The PostgreSQL connection pool.
|
|
* @throws Error if DATABASE_URL is not set.
|
|
*/
|
|
export function getPool(): Pool {
|
|
if (!pool) {
|
|
const connectionString = process.env['DATABASE_URL'];
|
|
if (!connectionString) {
|
|
throw new Error('DATABASE_URL environment variable is required');
|
|
}
|
|
|
|
const max = parseInt(process.env['DB_POOL_MAX'] ?? '20', 10);
|
|
const min = parseInt(process.env['DB_POOL_MIN'] ?? '2', 10);
|
|
const idleTimeoutMillis = parseInt(process.env['DB_POOL_IDLE_TIMEOUT_MS'] ?? '30000', 10);
|
|
const connectionTimeoutMillis = parseInt(
|
|
process.env['DB_POOL_CONNECTION_TIMEOUT_MS'] ?? '5000',
|
|
10,
|
|
);
|
|
|
|
pool = new Pool({
|
|
connectionString,
|
|
max,
|
|
min,
|
|
idleTimeoutMillis,
|
|
connectionTimeoutMillis,
|
|
});
|
|
|
|
pool.on('error', (err: Error) => {
|
|
// eslint-disable-next-line no-console
|
|
console.error('Unexpected pg pool error', err);
|
|
});
|
|
|
|
// Track active connections and waiting requests via pool events (task 2.3).
|
|
pool.on('acquire', () => {
|
|
if (pool) {
|
|
dbPoolActiveConnections.set(pool.totalCount - pool.idleCount);
|
|
dbPoolWaitingRequests.set(pool.waitingCount);
|
|
}
|
|
});
|
|
|
|
pool.on('remove', () => {
|
|
if (pool) {
|
|
dbPoolActiveConnections.set(pool.totalCount - pool.idleCount);
|
|
dbPoolWaitingRequests.set(pool.waitingCount);
|
|
}
|
|
});
|
|
|
|
pool.on('connect', () => {
|
|
if (pool) {
|
|
dbPoolActiveConnections.set(pool.totalCount - pool.idleCount);
|
|
dbPoolWaitingRequests.set(pool.waitingCount);
|
|
}
|
|
});
|
|
|
|
// Wrap pool.query to record duration in Prometheus.
|
|
// The pg Pool.query method is heavily overloaded — the only safe approach
|
|
// without TypeScript errors is a typed-any wrapper on the shim itself.
|
|
// We capture originalQuery as `(...args: any[]) => Promise<any>` to satisfy
|
|
// TypeScript's spread-into-rest constraint; this is the one sanctioned use of
|
|
// `any` in this file.
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const originalQuery = pool.query.bind(pool) as (...args: any[]) => Promise<any>;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(pool as any).query = async (...args: any[]): Promise<any> => {
|
|
const end = dbQueryDurationSeconds.startTimer({ operation: 'query' });
|
|
try {
|
|
return await originalQuery(...args);
|
|
} finally {
|
|
end();
|
|
}
|
|
};
|
|
}
|
|
return pool;
|
|
}
|
|
|
|
/**
|
|
* Closes the pool and resets the singleton.
|
|
* Used for graceful shutdown and tests.
|
|
*
|
|
* @returns Promise that resolves when the pool is closed.
|
|
*/
|
|
export async function closePool(): Promise<void> {
|
|
if (pool) {
|
|
await pool.end();
|
|
pool = null;
|
|
}
|
|
}
|