diff --git a/src/lock-error-classifier.mjs b/src/lock-error-classifier.mjs new file mode 100644 index 0000000..208f9a2 --- /dev/null +++ b/src/lock-error-classifier.mjs @@ -0,0 +1,89 @@ +export const PUBLISH_ERROR_CODES = { + TIMEOUT: 'publish_timeout', + DNS: 'dns_resolution', + TCP: 'tcp_connect_timeout', + TLS: 'tls_handshake', + WEBSOCKET: 'websocket_open_failure', + NETWORK: 'network_timeout', + CONNECTION_RESET: 'connection_reset', + RELAY_UNAVAILABLE: 'relay_unavailable', + PERMANENT: 'permanent_validation_error', +}; + +export const PUBLISH_FAILURE_CATEGORIES = { + QUORUM_FAILURE: 'relay_publish_quorum_failure', + NON_RETRYABLE: 'relay_publish_non_retryable', +}; + +/** + * Classifies a raw error message into a standardized publication error code. + * Used to determine if a failure is transient (retryable) or permanent. + * + * @param {string|Error} message - The error message or object to classify. + * @returns {string} One of the PUBLISH_ERROR_CODES constants. + */ +export function classifyPublishError(message) { + const normalized = String(message || '').toLowerCase(); + if (normalized.includes('publish timed out after') || normalized.includes('publish timeout')) { + return PUBLISH_ERROR_CODES.TIMEOUT; + } + if ( + normalized.includes('enotfound') + || normalized.includes('eai_again') + || normalized.includes('getaddrinfo') + || (normalized.includes('dns') && normalized.includes('websocket')) + ) { + return PUBLISH_ERROR_CODES.DNS; + } + if ( + normalized.includes('connect etimedout') + || normalized.includes('tcp connect timed out') + || normalized.includes('connect timeout') + ) { + return PUBLISH_ERROR_CODES.TCP; + } + if ( + normalized.includes('tls') + || normalized.includes('ssl') + || normalized.includes('certificate') + || normalized.includes('handshake') + ) { + return PUBLISH_ERROR_CODES.TLS; + } + if ( + normalized.includes('websocket') + || normalized.includes('bad response') + || normalized.includes('unexpected server response') + ) { + return PUBLISH_ERROR_CODES.WEBSOCKET; + } + if (normalized.includes('timed out') || normalized.includes('timeout') || normalized.includes('etimedout')) { + return PUBLISH_ERROR_CODES.NETWORK; + } + if (normalized.includes('econnreset') || normalized.includes('connection reset') || normalized.includes('socket hang up')) { + return PUBLISH_ERROR_CODES.CONNECTION_RESET; + } + if ( + normalized.includes('unavailable') + || normalized.includes('offline') + || normalized.includes('econnrefused') + || normalized.includes('connection refused') + || normalized.includes('503') + ) { + return PUBLISH_ERROR_CODES.RELAY_UNAVAILABLE; + } + return PUBLISH_ERROR_CODES.PERMANENT; +} + +export function isTransientPublishCategory(category) { + return [ + PUBLISH_ERROR_CODES.TIMEOUT, + PUBLISH_ERROR_CODES.DNS, + PUBLISH_ERROR_CODES.TCP, + PUBLISH_ERROR_CODES.TLS, + PUBLISH_ERROR_CODES.WEBSOCKET, + PUBLISH_ERROR_CODES.NETWORK, + PUBLISH_ERROR_CODES.CONNECTION_RESET, + PUBLISH_ERROR_CODES.RELAY_UNAVAILABLE, + ].includes(category); +} diff --git a/src/lock-ops.mjs b/src/lock-ops.mjs index 32891b6..d0ac6b1 100644 --- a/src/lock-ops.mjs +++ b/src/lock-ops.mjs @@ -10,7 +10,13 @@ import { } from './constants.mjs'; import { nowUnix } from './utils.mjs'; import { defaultHealthManager, buildRelayHealthConfig, RelayHealthManager } from './relay-health-manager.mjs'; -import { publishLock, LockPublisher, secureRandom } from './lock-publisher.mjs'; +import { publishLock, LockPublisher } from './lock-publisher.mjs'; +import { + withTimeout, + relayListLabel, + mergeRelayList, + secureRandom, +} from './lock-utils.mjs'; /** * Parses a raw Nostr event into a structured lock object. @@ -55,24 +61,6 @@ function filterActiveLocks(locks) { return locks.filter((lock) => !lock.expiresAt || lock.expiresAt > now); } -function withTimeout(promise, timeoutMs, timeoutMessage) { - let timeoutHandle; - const timeoutPromise = new Promise((_, reject) => { - timeoutHandle = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); - }); - return Promise.race([promise, timeoutPromise]).finally(() => { - if (timeoutHandle) clearTimeout(timeoutHandle); - }); -} - -function relayListLabel(relays) { - return relays.join(', '); -} - -function mergeRelayList(primaryRelays, fallbackRelays) { - return [...new Set([...primaryRelays, ...fallbackRelays])]; -} - /** * Queries relays for active lock events matching the criteria. * Uses a tiered approach: tries primary relays first, then falls back to others if needed. diff --git a/src/lock-publisher.mjs b/src/lock-publisher.mjs index b506f1b..c723555 100644 --- a/src/lock-publisher.mjs +++ b/src/lock-publisher.mjs @@ -1,4 +1,4 @@ -import { randomInt, randomUUID } from 'node:crypto'; +import { randomUUID } from 'node:crypto'; import { SimplePool } from 'nostr-tools/pool'; import { getPublishTimeoutMs, @@ -7,121 +7,17 @@ import { getMinActiveRelayPool, } from './torch-config.mjs'; import { defaultHealthManager, buildRelayHealthConfig } from './relay-health-manager.mjs'; - -function withTimeout(promise, timeoutMs, timeoutMessage) { - let timeoutHandle; - const timeoutPromise = new Promise((_, reject) => { - timeoutHandle = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); - }); - return Promise.race([promise, timeoutPromise]).finally(() => { - if (timeoutHandle) clearTimeout(timeoutHandle); - }); -} - -function relayListLabel(relays) { - return relays.join(', '); -} - -function mergeRelayList(primaryRelays, fallbackRelays) { - return [...new Set([...primaryRelays, ...fallbackRelays])]; -} - -const PUBLISH_ERROR_CODES = { - TIMEOUT: 'publish_timeout', - DNS: 'dns_resolution', - TCP: 'tcp_connect_timeout', - TLS: 'tls_handshake', - WEBSOCKET: 'websocket_open_failure', - NETWORK: 'network_timeout', - CONNECTION_RESET: 'connection_reset', - RELAY_UNAVAILABLE: 'relay_unavailable', - PERMANENT: 'permanent_validation_error', -}; - -const PUBLISH_FAILURE_CATEGORIES = { - QUORUM_FAILURE: 'relay_publish_quorum_failure', - NON_RETRYABLE: 'relay_publish_non_retryable', -}; - -/** - * Classifies a raw error message into a standardized publication error code. - * Used to determine if a failure is transient (retryable) or permanent. - * - * @param {string|Error} message - The error message or object to classify. - * @returns {string} One of the PUBLISH_ERROR_CODES constants. - */ -function classifyPublishError(message) { - const normalized = String(message || '').toLowerCase(); - if (normalized.includes('publish timed out after') || normalized.includes('publish timeout')) { - return PUBLISH_ERROR_CODES.TIMEOUT; - } - if ( - normalized.includes('enotfound') - || normalized.includes('eai_again') - || normalized.includes('getaddrinfo') - || (normalized.includes('dns') && normalized.includes('websocket')) - ) { - return PUBLISH_ERROR_CODES.DNS; - } - if ( - normalized.includes('connect etimedout') - || normalized.includes('tcp connect timed out') - || normalized.includes('connect timeout') - ) { - return PUBLISH_ERROR_CODES.TCP; - } - if ( - normalized.includes('tls') - || normalized.includes('ssl') - || normalized.includes('certificate') - || normalized.includes('handshake') - ) { - return PUBLISH_ERROR_CODES.TLS; - } - if ( - normalized.includes('websocket') - || normalized.includes('bad response') - || normalized.includes('unexpected server response') - ) { - return PUBLISH_ERROR_CODES.WEBSOCKET; - } - if (normalized.includes('timed out') || normalized.includes('timeout') || normalized.includes('etimedout')) { - return PUBLISH_ERROR_CODES.NETWORK; - } - if (normalized.includes('econnreset') || normalized.includes('connection reset') || normalized.includes('socket hang up')) { - return PUBLISH_ERROR_CODES.CONNECTION_RESET; - } - if ( - normalized.includes('unavailable') - || normalized.includes('offline') - || normalized.includes('econnrefused') - || normalized.includes('connection refused') - || normalized.includes('enotfound') - || normalized.includes('503') - ) { - return PUBLISH_ERROR_CODES.RELAY_UNAVAILABLE; - } - return PUBLISH_ERROR_CODES.PERMANENT; -} - -function isTransientPublishCategory(category) { - return [ - PUBLISH_ERROR_CODES.TIMEOUT, - PUBLISH_ERROR_CODES.DNS, - PUBLISH_ERROR_CODES.TCP, - PUBLISH_ERROR_CODES.TLS, - PUBLISH_ERROR_CODES.WEBSOCKET, - PUBLISH_ERROR_CODES.NETWORK, - PUBLISH_ERROR_CODES.CONNECTION_RESET, - PUBLISH_ERROR_CODES.RELAY_UNAVAILABLE, - ].includes(category); -} - -const MAX_RANDOM = 281474976710655; // 2**48 - 1 - -export function secureRandom() { - return randomInt(0, MAX_RANDOM) / MAX_RANDOM; -} +import { + withTimeout, + relayListLabel, + mergeRelayList, + secureRandom, +} from './lock-utils.mjs'; +import { + PUBLISH_FAILURE_CATEGORIES, + classifyPublishError, + isTransientPublishCategory, +} from './lock-error-classifier.mjs'; function calculateBackoffDelayMs(attemptNumber, baseMs, capMs, randomFn = secureRandom) { const maxDelay = Math.min(capMs, baseMs * (2 ** Math.max(0, attemptNumber - 1))); diff --git a/src/lock-utils.mjs b/src/lock-utils.mjs index cac3fdc..bf3abf7 100644 --- a/src/lock-utils.mjs +++ b/src/lock-utils.mjs @@ -1,5 +1,6 @@ import fs from 'node:fs/promises'; import path from 'node:path'; +import { randomInt } from 'node:crypto'; import { todayDateStr, getIsoWeekStr } from './utils.mjs'; /** @@ -65,3 +66,9 @@ export function relayListLabel(relays) { export function mergeRelayList(primaryRelays, fallbackRelays) { return [...new Set([...primaryRelays, ...fallbackRelays])]; } + +const MAX_RANDOM = 281474976710655; // 2**48 - 1 + +export function secureRandom() { + return randomInt(0, MAX_RANDOM) / MAX_RANDOM; +} diff --git a/torch-config-test.json b/torch-config-test.json deleted file mode 100644 index 3205b36..0000000 --- a/torch-config-test.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "dashboard": { - "auth": "admin:password123", - "relays": [ - "wss://secure.relay.com" - ] - } -} \ No newline at end of file