diff --git a/src/Defaults/index.ts b/src/Defaults/index.ts index 3bc955dd..4c8f97a9 100644 --- a/src/Defaults/index.ts +++ b/src/Defaults/index.ts @@ -77,7 +77,9 @@ export const DEFAULT_CONNECTION_CONFIG: SocketConfig = { countryCode: 'US', getMessage: async () => undefined, cachedGroupMetadata: async () => undefined, - makeSignalRepository: makeLibSignalRepository + makeSignalRepository: makeLibSignalRepository, + // Circuit breaker configuration + enableCircuitBreaker: true } export const MEDIA_PATH_MAP: { [T in MediaType]?: string } = { diff --git a/src/Socket/socket.ts b/src/Socket/socket.ts index 8aeb02ab..08f45ac4 100644 --- a/src/Socket/socket.ts +++ b/src/Socket/socket.ts @@ -35,6 +35,12 @@ import { xmppSignedPreKey } from '../Utils' import { getPlatformId } from '../Utils/browser-utils' +import { + CircuitBreaker, + CircuitOpenError, + createConnectionCircuitBreaker, + createPreKeyCircuitBreaker +} from '../Utils/circuit-breaker' import { assertNodeErrorFree, type BinaryNode, @@ -71,9 +77,64 @@ export const makeSocket = (config: SocketConfig) => { defaultQueryTimeoutMs, transactionOpts, qrTimeout, - makeSignalRepository + makeSignalRepository, + enableCircuitBreaker = true, + queryCircuitBreaker: queryCircuitBreakerConfig, + connectionCircuitBreaker: connectionCircuitBreakerConfig, + preKeyCircuitBreaker: preKeyCircuitBreakerConfig } = config + // Initialize circuit breakers if enabled + let queryCircuitBreaker: CircuitBreaker | undefined + let connectionCircuitBreaker: CircuitBreaker | undefined + let preKeyCircuitBreaker: CircuitBreaker | undefined + + if (enableCircuitBreaker) { + // Circuit breaker for query operations (most critical) + queryCircuitBreaker = createConnectionCircuitBreaker({ + name: 'socket-query', + failureThreshold: 5, + failureWindow: 60000, + resetTimeout: 30000, + successThreshold: 2, + timeout: defaultQueryTimeoutMs || 60000, + onStateChange: (from, to) => { + logger.info({ from, to }, 'Query circuit breaker state changed') + }, + onOpen: () => { + logger.warn('Query circuit breaker OPENED - blocking requests') + }, + onClose: () => { + logger.info('Query circuit breaker CLOSED - resuming normal operation') + }, + ...queryCircuitBreakerConfig + }) + + // Circuit breaker for connection operations + connectionCircuitBreaker = createConnectionCircuitBreaker({ + name: 'socket-connection', + failureThreshold: 3, + failureWindow: 30000, + resetTimeout: 60000, + successThreshold: 1, + onStateChange: (from, to) => { + logger.info({ from, to }, 'Connection circuit breaker state changed') + }, + ...connectionCircuitBreakerConfig + }) + + // Circuit breaker for pre-key operations + preKeyCircuitBreaker = createPreKeyCircuitBreaker({ + name: 'socket-prekey', + onStateChange: (from, to) => { + logger.info({ from, to }, 'PreKey circuit breaker state changed') + }, + ...preKeyCircuitBreakerConfig + }) + + logger.info('Circuit breakers initialized for socket operations') + } + const publicWAMBuffer = new BinaryInfo() const uqTagId = generateMdTagPrefix() @@ -110,8 +171,8 @@ export const makeSocket = (config: SocketConfig) => { ws.connect() const sendPromise = promisify(ws.send) - /** send a raw buffer */ - const sendRawMessage = async (data: Uint8Array | Buffer) => { + /** send a raw buffer (internal implementation) */ + const sendRawMessageInternal = async (data: Uint8Array | Buffer) => { if (!ws.isOpen) { throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed }) } @@ -127,6 +188,22 @@ export const makeSocket = (config: SocketConfig) => { }) } + /** send a raw buffer with circuit breaker protection */ + const sendRawMessage = async (data: Uint8Array | Buffer) => { + if (connectionCircuitBreaker) { + try { + return await connectionCircuitBreaker.execute(() => sendRawMessageInternal(data)) + } catch (error) { + if (error instanceof CircuitOpenError) { + logger.warn({ circuitName: error.circuitName }, 'Send blocked by connection circuit breaker') + } + throw error + } + } + + return sendRawMessageInternal(data) + } + /** send a binary node */ const sendNode = (frame: BinaryNode) => { if (logger.level === 'trace') { @@ -185,7 +262,7 @@ export const makeSocket = (config: SocketConfig) => { } /** send a query, and wait for its response. auto-generates message ID if not provided */ - const query = async (node: BinaryNode, timeoutMs?: number) => { + const queryInternal = async (node: BinaryNode, timeoutMs?: number) => { if (!node.attrs.id) { node.attrs.id = generateMessageTag() } @@ -206,6 +283,25 @@ export const makeSocket = (config: SocketConfig) => { return result } + /** send a query with circuit breaker protection */ + const query = async (node: BinaryNode, timeoutMs?: number) => { + // If circuit breaker is enabled, wrap the query + if (queryCircuitBreaker) { + try { + return await queryCircuitBreaker.execute(() => queryInternal(node, timeoutMs)) + } catch (error) { + // If circuit is open, log and rethrow with context + if (error instanceof CircuitOpenError) { + logger.warn({ circuitName: error.circuitName, state: error.state }, 'Query blocked by circuit breaker') + } + throw error + } + } + + // Fallback to direct query if circuit breaker is disabled + return queryInternal(node, timeoutMs) + } + // Validate current key-bundle on server; on failure, trigger pre-key upload and rethrow const digestKeyBundle = async (): Promise => { const res = await query({ @@ -460,6 +556,12 @@ export const makeSocket = (config: SocketConfig) => { /** generates and uploads a set of pre-keys to the server */ const uploadPreKeys = async (count = MIN_PREKEY_COUNT, retryCount = 0) => { + // Check if pre-key circuit breaker is open + if (preKeyCircuitBreaker?.isOpen()) { + logger.warn('PreKey circuit breaker is open, skipping upload') + throw new CircuitOpenError('socket-prekey', 'open') + } + // Check minimum interval (except for retries) if (retryCount === 0) { const timeSinceLastUpload = Date.now() - lastUploadTime @@ -487,14 +589,28 @@ export const makeSocket = (config: SocketConfig) => { return node // Only return node since update is already used }, creds?.me?.id || 'upload-pre-keys') - // Upload to server (outside transaction, can fail without affecting local keys) - try { + // Upload to server with circuit breaker protection + const uploadToServer = async () => { await query(node) logger.info({ count }, 'uploaded pre-keys successfully') lastUploadTime = Date.now() + } + + try { + // Use circuit breaker if available + if (preKeyCircuitBreaker) { + await preKeyCircuitBreaker.execute(uploadToServer) + } else { + await uploadToServer() + } } catch (uploadError) { logger.error({ uploadError: (uploadError as Error).toString(), count }, 'Failed to upload pre-keys to server') + // Don't retry if circuit breaker is open + if (uploadError instanceof CircuitOpenError) { + throw uploadError + } + // Exponential backoff retry (max 3 retries) if (retryCount < 3) { const backoffDelay = Math.min(1000 * Math.pow(2, retryCount), 10000) @@ -617,6 +733,11 @@ export const makeSocket = (config: SocketConfig) => { clearInterval(keepAliveReq) clearTimeout(qrTimer) + // Clean up circuit breakers + queryCircuitBreaker?.destroy() + connectionCircuitBreaker?.destroy() + preKeyCircuitBreaker?.destroy() + ws.removeAllListeners('close') ws.removeAllListeners('open') ws.removeAllListeners('message') @@ -1058,7 +1179,26 @@ export const makeSocket = (config: SocketConfig) => { waitForConnectionUpdate: bindWaitForConnectionUpdate(ev), sendWAMBuffer, executeUSyncQuery, - onWhatsApp + onWhatsApp, + // Circuit breaker utilities + circuitBreakers: { + query: queryCircuitBreaker, + connection: connectionCircuitBreaker, + preKey: preKeyCircuitBreaker + }, + /** Get circuit breaker statistics */ + getCircuitBreakerStats: () => ({ + query: queryCircuitBreaker?.getStats(), + connection: connectionCircuitBreaker?.getStats(), + preKey: preKeyCircuitBreaker?.getStats() + }), + /** Reset all circuit breakers to closed state */ + resetCircuitBreakers: () => { + queryCircuitBreaker?.reset() + connectionCircuitBreaker?.reset() + preKeyCircuitBreaker?.reset() + logger.info('All circuit breakers reset to closed state') + } } } diff --git a/src/Types/Socket.ts b/src/Types/Socket.ts index eba8779c..62ab06b5 100644 --- a/src/Types/Socket.ts +++ b/src/Types/Socket.ts @@ -2,6 +2,7 @@ import type { Agent } from 'https' import type { URL } from 'url' import { proto } from '../../WAProto/index.js' import type { ILogger } from '../Utils/logger' +import type { CircuitBreakerOptions } from '../Utils/circuit-breaker' import type { AuthenticationState, LIDMapping, SignalAuthState, TransactionCapabilityOptions } from './Auth' import type { GroupMetadata } from './GroupMetadata' import { type MediaConnInfo, type WAMessageKey } from './Message' @@ -147,4 +148,21 @@ export type SocketConfig = { logger: ILogger, pnToLIDFunc?: (jids: string[]) => Promise ) => SignalRepositoryWithLIDStore + + // === Circuit Breaker Configuration === + + /** Enable circuit breaker protection for socket operations (default: true) */ + enableCircuitBreaker?: boolean + + /** Circuit breaker configuration for query operations */ + queryCircuitBreaker?: Partial + + /** Circuit breaker configuration for connection operations */ + connectionCircuitBreaker?: Partial + + /** Circuit breaker configuration for pre-key operations */ + preKeyCircuitBreaker?: Partial + + /** Circuit breaker configuration for message operations */ + messageCircuitBreaker?: Partial } diff --git a/src/Utils/baileys-event-stream.ts b/src/Utils/baileys-event-stream.ts index 2a62d18b..7a90933b 100644 --- a/src/Utils/baileys-event-stream.ts +++ b/src/Utils/baileys-event-stream.ts @@ -321,7 +321,8 @@ export class BaileysEventStream extends EventEmitter { // Find correct position let insertIndex = this.buffer.length for (let i = 0; i < this.buffer.length; i++) { - if (PRIORITY_VALUES[this.buffer[i].priority] > eventPriorityValue) { + const bufferEvent = this.buffer[i] + if (bufferEvent && PRIORITY_VALUES[bufferEvent.priority] > eventPriorityValue) { insertIndex = i break } diff --git a/src/Utils/baileys-logger.ts b/src/Utils/baileys-logger.ts index 54400338..ccb49b38 100644 --- a/src/Utils/baileys-logger.ts +++ b/src/Utils/baileys-logger.ts @@ -331,8 +331,29 @@ export class BaileysLogger implements ILogger { ...(typeof sanitizedObj === 'object' && sanitizedObj !== null ? sanitizedObj : { value: sanitizedObj }), } - // Structured log - this.structuredLogger[level](enrichedObj, msg) + // Structured log (using type-safe method call) + switch (level) { + case 'trace': + this.structuredLogger.trace(enrichedObj, msg) + break + case 'debug': + this.structuredLogger.debug(enrichedObj, msg) + break + case 'info': + this.structuredLogger.info(enrichedObj, msg) + break + case 'warn': + this.structuredLogger.warn(enrichedObj, msg) + break + case 'error': + this.structuredLogger.error(enrichedObj, msg) + break + case 'fatal': + this.structuredLogger.fatal(enrichedObj, msg) + break + default: + this.structuredLogger.info(enrichedObj, msg) + } // Event handler if (this.config.eventHandler) { @@ -429,8 +450,10 @@ export class BaileysLogger implements ILogger { if (process.env.NODE_ENV === 'production') { // In production, mask part of the number const parts = jid.split('@') - if (parts.length === 2 && parts[0].length > 4) { - return `${parts[0].substring(0, 4)}****@${parts[1]}` + const localPart = parts[0] + const domainPart = parts[1] + if (parts.length === 2 && localPart && domainPart && localPart.length > 4) { + return `${localPart.substring(0, 4)}****@${domainPart}` } } return jid diff --git a/src/Utils/cache-utils.ts b/src/Utils/cache-utils.ts index 22772d22..5746ddc7 100644 --- a/src/Utils/cache-utils.ts +++ b/src/Utils/cache-utils.ts @@ -500,7 +500,8 @@ export function withCache unknown>( /** * Global singleton cache by namespace */ -const globalCaches: Map> = new Map() +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const globalCaches: Map> = new Map() export function getGlobalCache(namespace: string, options?: CacheOptions): Cache { if (!globalCaches.has(namespace)) { diff --git a/src/Utils/logger-adapter.ts b/src/Utils/logger-adapter.ts index 141d7188..48ce0227 100644 --- a/src/Utils/logger-adapter.ts +++ b/src/Utils/logger-adapter.ts @@ -120,7 +120,7 @@ export class LoggerAdapter implements ILogger { } if (this.config.levelMapping && level in this.config.levelMapping) { - return this.config.levelMapping[level] + return this.config.levelMapping[level] || 'info' } return (level as LogLevel) || 'info' @@ -327,8 +327,12 @@ function createLogMethod( return (obj: unknown, msg?: string) => { if (typeof logger[level] === 'function') { ;(logger[level] as (obj: unknown, msg?: string) => void)(obj, msg) - } else if (typeof (console as Record)[level] === 'function') { - ;(console as Record void>)[level](obj, msg) + } else { + // Fallback to console + const consoleMethod = (console as unknown as Record void>)[level] + if (typeof consoleMethod === 'function') { + consoleMethod(obj, msg) + } } } } diff --git a/src/Utils/prometheus-metrics.ts b/src/Utils/prometheus-metrics.ts index 70e1863b..7e64e5f4 100644 --- a/src/Utils/prometheus-metrics.ts +++ b/src/Utils/prometheus-metrics.ts @@ -612,10 +612,10 @@ export class MetricsRegistry { /** * Return metrics in Prometheus format */ - async metrics(): Promise { + async getMetricsOutput(): Promise { const lines: string[] = [] - for (const [name, metric] of this.metrics) { + for (const [name, metric] of this.metrics.entries()) { lines.push(`# HELP ${name} ${metric.help}`) lines.push(`# TYPE ${name} ${metric.type}`) @@ -776,7 +776,7 @@ export const metrics = { */ export function createMetricsHandler(registry: MetricsRegistry = baileysMetrics) { return async (_req: unknown, res: { setHeader: (name: string, value: string) => void; end: (body: string) => void }) => { - const metricsOutput = await registry.metrics() + const metricsOutput = await registry.getMetricsOutput() res.setHeader('Content-Type', registry.contentType()) res.end(metricsOutput) } diff --git a/src/Utils/trace-context.ts b/src/Utils/trace-context.ts index 398baa49..2f0e0dbe 100644 --- a/src/Utils/trace-context.ts +++ b/src/Utils/trace-context.ts @@ -554,9 +554,10 @@ export function extractTraceHeaders(headers: Record) } // Parse baggage - if (headers[TRACE_HEADERS.BAGGAGE]) { + const baggageHeader = headers[TRACE_HEADERS.BAGGAGE] + if (baggageHeader) { options.baggage = {} - const pairs = headers[TRACE_HEADERS.BAGGAGE].split(',') + const pairs = baggageHeader.split(',') for (const pair of pairs) { const [key, value] = pair.split('=') if (key && value) {