diff --git a/backend/.env.example b/backend/.env.example index dd3cc3a1..cae3f151 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -157,10 +157,37 @@ OG_RENDER_TIMEOUT_MS=5000 # X (Twitter) API — credit score signals X_API_BEARER_TOKEN= X_API_BASE_URL=https://api.twitter.com/2 +# X API timeout (ms) — explicit per-request timeout for X API calls (issue #090) +X_API_TIMEOUT_MS=10000 # IPFS (profile images) IPFS_API_URL= IPFS_GATEWAY_URL=https://ipfs.io/ipfs/ +# IPFS pinning timeout (ms) — explicit timeout for IPFS HTTP pin (issue #090) +IPFS_TIMEOUT_MS=15000 + +# ── Timeouts & resilience (issues #090, #091, #092, #077) ────────────────── +# Outbound HTTP timeouts — explicit per-dependency, configurable via env (issue #090) +SOROBAN_RPC_TIMEOUT_MS=10000 +HORIZON_TIMEOUT_MS=8000 +REQUEST_TIMEOUT_MS=30000 +# Circuit breaker — threshold / reset timeout per dependency (issue #091) +CIRCUIT_BREAKER_THRESHOLD=5 +CIRCUIT_BREAKER_RESET_TIMEOUT_MS=30000 +RPC_CIRCUIT_BREAKER_THRESHOLD=5 +RPC_CIRCUIT_BREAKER_RESET_TIMEOUT_MS=30000 +HORIZON_CIRCUIT_BREAKER_THRESHOLD=5 +HORIZON_CIRCUIT_BREAKER_RESET_TIMEOUT_MS=30000 +# Retry with jitter — exponential backoff (issue #092) +RETRY_MAX_ATTEMPTS=3 +RETRY_INITIAL_DELAY_MS=100 +RETRY_MAX_DELAY_MS=5000 +RETRY_FACTOR=2 +# Payload limits — right-sized per-route (issue #077) +JSON_BODY_LIMIT=100kb +MULTER_FILE_SIZE_LIMIT=5242880 +MULTER_FILES_LIMIT=1 +MULTER_FIELDS_LIMIT=10 # Email / notifications (optional providers) SMTP_URL= diff --git a/backend/src/app.ts b/backend/src/app.ts index e4756b7e..3c86108c 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -11,7 +11,9 @@ import { metricsController, metricsMiddleware } from './common/observability/met import { getSentryRequestHandler, getSentryErrorHandler } from './common/observability/sentry.js'; import { logger } from './common/utils/logger.js'; import { requestId } from './common/middleware/requestId.js'; +import { requestTimeoutAndSignal } from './common/middleware/requestTimeout.js'; import { healthRouter } from './modules/health/health.routes.js'; +import { config } from './config/index.js'; /** * HSTS max-age: 1 year (31536000 seconds). @@ -164,9 +166,25 @@ export function createApp(): Express { ); app.use(globalRateLimiter); app.use(requestId); + // Server-level timeout + client-disconnect AbortSignal (issue #090) + app.use(requestTimeoutAndSignal); app.use(metricsMiddleware); - // Must parse both JSON and CSP report bodies (browsers send application/csp-report) - app.use(express.json({ limit: '1mb', type: ['application/json', 'application/csp-report'] })); + /** + * Right-sized JSON limits (issue #077). + * Default is tight (100kb, configurable via JSON_BODY_LIMIT) — far below the old 1mb blanket. + * Routes that legitimately need more (tip/profile writes) get a larger explicit limit via + * adaptiveJsonLimit below. Oversized bodies are mapped to 413 PAYLOAD_TOO_LARGE in errorHandler. + */ + const defaultJsonLimit = (config as unknown as { payload?: { jsonLimit: string } })?.payload?.jsonLimit ?? '100kb'; // e.g. '100kb' + const largeJsonLimit = '500kb'; + // Paths that need larger JSON bodies (documented per-route override) + const largeJsonPrefixes = [`${(config as unknown as { server?: { apiBasePath: string } })?.server?.apiBasePath ?? '/api/v1'}/tips`, `${(config as unknown as { server?: { apiBasePath: string } })?.server?.apiBasePath ?? '/api/v1'}/profiles`, `${(config as unknown as { server?: { apiBasePath: string } })?.server?.apiBasePath ?? '/api/v1'}/auth`]; + const adaptiveJsonLimit = (req: express.Request, _res: express.Response, next: express.NextFunction) => { + const needsLarge = largeJsonPrefixes.some((prefix) => req.path.startsWith(prefix) || req.originalUrl.startsWith(prefix)); + const limit = needsLarge ? largeJsonLimit : defaultJsonLimit; + return express.json({ limit, type: ['application/json', 'application/csp-report'] })(req, _res, next); + }; + app.use(adaptiveJsonLimit); app.use(pinoHttp({ logger })); app.get('/metrics', metricsController); diff --git a/backend/src/common/errors/AppError.ts b/backend/src/common/errors/AppError.ts index 766d6163..30c59831 100644 --- a/backend/src/common/errors/AppError.ts +++ b/backend/src/common/errors/AppError.ts @@ -59,3 +59,15 @@ export class TooManyRequestsError extends AppError { } } +export class PayloadTooLargeError extends AppError { + constructor(message = 'Payload too large', details?: unknown) { + super(413, message, 'PAYLOAD_TOO_LARGE', details); + } +} + +export class RequestTimeoutError extends AppError { + constructor(message = 'Request timeout', details?: unknown) { + super(503, message, 'REQUEST_TIMEOUT', details); + } +} + diff --git a/backend/src/common/middleware/errorHandler.ts b/backend/src/common/middleware/errorHandler.ts index 8cfb847a..8f80be54 100644 --- a/backend/src/common/middleware/errorHandler.ts +++ b/backend/src/common/middleware/errorHandler.ts @@ -23,6 +23,48 @@ export function errorHandler( _next: NextFunction, ): void { const requestId = requestIdOf(req); + + // Multer errors (file size/count) — return 413 in app error format, not HTML + const multerErr = err as { code?: string; status?: number; type?: string; limit?: string }; + if (multerErr?.code === 'LIMIT_FILE_SIZE' || multerErr?.code === 'LIMIT_FILE_COUNT' || multerErr?.code === 'LIMIT_FIELD_COUNT' || multerErr?.code === 'LIMIT_UNEXPECTED_FILE') { + res.status(413).json({ + error: { code: 'PAYLOAD_TOO_LARGE', message: 'Payload too large', details: { limit: multerErr.code }, requestId }, + }); + return; + } + + // Express json entity.too.large (body > limit) — also 413 with JSON envelope + if ( + (err as { type?: string; status?: number })?.type === 'entity.too.large' || + (err as { status?: number })?.status === 413 || + (err as { statusCode?: number })?.statusCode === 413 + ) { + res.status(413).json({ + error: { code: 'PAYLOAD_TOO_LARGE', message: 'Payload too large', requestId }, + }); + return; + } + + // TimeoutError from AbortSignal.timeout (upstream) mapped to 503/504? unify to 503 REQUEST_TIMEOUT + if (err instanceof DOMException && err.name === 'TimeoutError') { + res.status(503).json({ + error: { code: 'REQUEST_TIMEOUT', message: err.message || 'Upstream request timed out', requestId }, + }); + return; + } + if (err instanceof DOMException && err.name === 'AbortError') { + // Client disconnect abort — if headers already sent, ignore; otherwise map to 499 or 503 + if (res.headersSent) return; + // If request was aborted by server timeout, upstream already responded 503; avoid double response + if ((req as unknown as { signal?: AbortSignal })?.signal?.aborted) { + // If client disconnected, log and return 503 with cancellation code + res.status(503).json({ + error: { code: 'REQUEST_CANCELLED', message: 'Request cancelled', requestId }, + }); + return; + } + } + if (err instanceof ZodError) { res.status(400).json({ error: { diff --git a/backend/src/common/middleware/requestTimeout.ts b/backend/src/common/middleware/requestTimeout.ts new file mode 100644 index 00000000..6dd28764 --- /dev/null +++ b/backend/src/common/middleware/requestTimeout.ts @@ -0,0 +1,63 @@ +import type { NextFunction, Request, Response } from "express"; +import { config } from "../../config/index.js"; +import { logger } from "../utils/logger.js"; + +/** + * Request timeout + client-disconnect AbortSignal middleware (issue #090). + * + * - Creates a per-request AbortController and exposes its signal as `req.signal`. + * Upstream fetch/RPC calls should pass this signal via fetchWithTimeout({ parentSignal: req.signal }) + * or withTimeoutAndSignal(..., req.signal). When the client disconnects, the signal aborts, + * cancelling in-flight work. + * - Enforces a server-level request timeout (config.timeouts.requestMs, default 30s). + * If the timeout fires before the response is sent, it responds 503 with code REQUEST_TIMEOUT + * and aborts the controller so upstream work is cancelled. + */ + +export function requestTimeoutAndSignal(req: Request, res: Response, next: NextFunction): void { + const timeoutMs = (config as unknown as { timeouts?: { requestMs: number } })?.timeouts?.requestMs ?? 30_000; + const controller = new AbortController(); + // Expose signal on request for downstream handlers + (req as unknown as { signal: AbortSignal }).signal = controller.signal; + + let timedOut = false; + const timeout = setTimeout(() => { + if (res.headersSent || res.writableEnded) return; + timedOut = true; + logger.warn({ path: req.path, method: req.method, timeoutMs }, "Request timed out — returning 503"); + controller.abort(new DOMException("Request timeout", "TimeoutError")); + if (!res.headersSent) { + res.status(503).json({ + error: { + code: "REQUEST_TIMEOUT", + message: `Request timed out after ${timeoutMs}ms`, + requestId: (req as unknown as { id?: string }).id, + }, + }); + } + }, timeoutMs); + + // Client disconnect -> abort upstream + const onClose = () => { + if (!res.writableEnded && !timedOut) { + logger.debug({ path: req.path, method: req.method }, "Client disconnected — aborting upstream"); + controller.abort(new DOMException("Client disconnected", "AbortError")); + } + cleanup(); + }; + + const cleanup = () => { + clearTimeout(timeout); + req.removeListener("close", onClose); + res.removeListener("finish", cleanup); + res.removeListener("close", cleanup); + }; + + req.on("close", onClose); + res.on("finish", cleanup); + res.on("close", cleanup); + + // If response already wants to abort upstream on its own close, ensure controller abort doesn't double-send + // Pass through + next(); +} diff --git a/backend/src/common/observability/metrics.ts b/backend/src/common/observability/metrics.ts index 146403c7..353148aa 100644 --- a/backend/src/common/observability/metrics.ts +++ b/backend/src/common/observability/metrics.ts @@ -41,6 +41,14 @@ export interface MetricsData { retention: { rows_pruned_total: Record; }; + circuitBreaker?: Record; + timeouts?: { + request_timeout_ms: number; + rpc_timeout_ms: number; + horizon_timeout_ms: number; + ipfs_timeout_ms: number; + x_api_timeout_ms: number; + }; } let requestCount = 0; @@ -48,6 +56,7 @@ let errorCount = 0; let latencySum = 0; let latencyCount = 0; let slowQueryCount = 0; +let poolSaturationCount = 0; const retentionPrunedCounts: Record = {}; export function recordRequest(duration: number) { @@ -65,6 +74,10 @@ export function recordSlowQuery() { slowQueryCount++; } +export function recordPoolSaturation(): void { + poolSaturationCount++; +} + /** Records rows removed by one completed retention batch. */ export function recordRetentionPruned(model: string, count: number): void { retentionPrunedCounts[model] = (retentionPrunedCounts[model] ?? 0) + count; @@ -88,6 +101,15 @@ export async function getMetrics(): Promise { } } + // Circuit breaker states (issue #091) — lazy import to avoid cycle + let circuitBreaker: Record | undefined; + try { + const { getCircuitBreakerMetrics } = await import('../utils/circuitBreaker.js'); + circuitBreaker = getCircuitBreakerMetrics(); + } catch { + circuitBreaker = undefined; + } + return { timestamp: new Date().toISOString(), service: 'stellar-tipz-backend', @@ -123,6 +145,14 @@ export async function getMetrics(): Promise { retention: { rows_pruned_total: { ...retentionPrunedCounts }, }, + circuitBreaker, + timeouts: { + request_timeout_ms: env.REQUEST_TIMEOUT_MS, + rpc_timeout_ms: env.SOROBAN_RPC_TIMEOUT_MS, + horizon_timeout_ms: env.HORIZON_TIMEOUT_MS, + ipfs_timeout_ms: env.IPFS_TIMEOUT_MS, + x_api_timeout_ms: env.X_API_TIMEOUT_MS, + }, }; } diff --git a/backend/src/common/stellar/rpcClient.ts b/backend/src/common/stellar/rpcClient.ts new file mode 100644 index 00000000..27de0882 --- /dev/null +++ b/backend/src/common/stellar/rpcClient.ts @@ -0,0 +1,81 @@ +import { SorobanRpc } from "@stellar/stellar-sdk"; +import { config } from "../../config/index.js"; +import { CircuitBreaker } from "../utils/circuitBreaker.js"; +import { withTimeoutAndSignal } from "../utils/fetchWithTimeout.js"; +import { logger } from "../utils/logger.js"; + +/** + * Centralized Soroban RPC and Horizon resilience layer (issues #090, #091). + * - Timeouts via withTimeoutAndSignal (configurable SOROBAN_RPC_TIMEOUT_MS / HORIZON_TIMEOUT_MS) + * - Circuit breaker fast-fails when upstream degrades + * - Client disconnect cancellation via parentSignal (req.signal) + */ + +// Shared breaker instances — state exposed via metrics endpoint (issue #091) +// Use optional chaining with defaults so mocked config in tests (e.g. securityHeaders.test) doesn't crash at import time +export const rpcCircuitBreaker = new CircuitBreaker( + (config as unknown as { circuitBreaker?: { rpcThreshold: number } })?.circuitBreaker?.rpcThreshold ?? 5, + (config as unknown as { circuitBreaker?: { rpcResetTimeoutMs: number } })?.circuitBreaker?.rpcResetTimeoutMs ?? 30_000, + "Soroban RPC", +); + +export const horizonCircuitBreaker = new CircuitBreaker( + (config as unknown as { circuitBreaker?: { horizonThreshold: number } })?.circuitBreaker?.horizonThreshold ?? 5, + (config as unknown as { circuitBreaker?: { horizonResetTimeoutMs: number } })?.circuitBreaker?.horizonResetTimeoutMs ?? 30_000, + "Horizon", +); + +export function getRpcServer(): SorobanRpc.Server { + const rpcUrl = (config as unknown as { stellar?: { rpcUrl: string } })?.stellar?.rpcUrl ?? "https://soroban-testnet.stellar.org"; + return new SorobanRpc.Server(rpcUrl, { + allowHttp: rpcUrl.startsWith("http://"), + }); +} + +/** + * Wraps an RPC operation with circuit breaker and timeout. + * Use this for every SorobanRpc.Server call (getAccount, simulateTransaction, sendTransaction, getHealth). + */ +export async function rpcCall( + operation: (server: SorobanRpc.Server) => Promise, + opts: { signal?: AbortSignal; timeoutMs?: number; operationName?: string } = {}, +): Promise { + const timeoutMs = opts.timeoutMs ?? (config as unknown as { timeouts?: { sorobanRpcMs: number } })?.timeouts?.sorobanRpcMs ?? 10_000; + const name = opts.operationName ?? "RPC call"; + + return rpcCircuitBreaker.call(async () => { + const server = getRpcServer(); + const promise = operation(server); + try { + return await withTimeoutAndSignal(promise, timeoutMs, opts.signal, name); + } catch (err) { + if (err instanceof DOMException && err.name === "TimeoutError") { + logger.warn({ operation: name, timeoutMs }, "Soroban RPC timeout"); + } + throw err; + } + }); +} + +/** + * Horizon fetch wrapper with same resilience properties. + */ +export async function horizonFetch( + path: string, + options: RequestInit & { signal?: AbortSignal; timeoutMs?: number } = {}, +): Promise { + const timeoutMs = (options as unknown as { timeoutMs?: number }).timeoutMs ?? (config as unknown as { timeouts?: { horizonMs: number } })?.timeouts?.horizonMs ?? 8_000; + const signal = (options as unknown as { signal?: AbortSignal }).signal; + const horizonUrl = (config as unknown as { stellar?: { horizonUrl: string } })?.stellar?.horizonUrl ?? "https://horizon-testnet.stellar.org"; + const url = `${horizonUrl.replace(/\/+$/, "")}${path}`; + + return horizonCircuitBreaker.call(async () => { + // Import here to avoid cycle + const { fetchWithTimeout } = await import("../utils/fetchWithTimeout.js"); + return fetchWithTimeout(url, { + ...options, + timeoutMs, + parentSignal: signal, + }); + }); +} diff --git a/backend/src/common/types/express.d.ts b/backend/src/common/types/express.d.ts index 922e30ea..8845f0db 100644 --- a/backend/src/common/types/express.d.ts +++ b/backend/src/common/types/express.d.ts @@ -4,6 +4,8 @@ declare global { namespace Express { interface Request { user?: AuthUser; + id?: string; + signal?: AbortSignal; } } } diff --git a/backend/src/common/utils/circuitBreaker.ts b/backend/src/common/utils/circuitBreaker.ts new file mode 100644 index 00000000..b37aae54 --- /dev/null +++ b/backend/src/common/utils/circuitBreaker.ts @@ -0,0 +1,132 @@ +import { ServiceUnavailableError } from "../errors/AppError.js"; +import { logger } from "./logger.js"; + +/** + * Shared circuit breaker utility (issue #091). + * Generic implementation extracted from x.circuit-breaker.ts — behaviour must stay identical + * so x.circuit-breaker.test.ts passes unmodified. + * + * State machine: CLOSED -> OPEN (after threshold failures) -> HALF_OPEN (after reset timeout) -> CLOSED or OPEN. + */ + +export type CircuitBreakerState = "CLOSED" | "OPEN" | "HALF_OPEN"; + +export interface CircuitBreakerOptions { + /** Human-readable name for logging/metrics (e.g. "X API", "Soroban RPC", "Horizon"). */ + name?: string; + failureThreshold?: number; + resetTimeoutMs?: number; +} + +const breakerMetrics: Map = new Map(); + +export function getCircuitBreakerMetrics(): Record { + const out: Record = {}; + for (const [k, v] of breakerMetrics.entries()) out[k] = { ...v }; + return out; +} + +export function recordCircuitBreakerState(name: string, state: CircuitBreakerState, failures: number): void { + const existing = breakerMetrics.get(name) ?? { state: "CLOSED", failures: 0, opens: 0 }; + if (state === "OPEN" && existing.state !== "OPEN") { + existing.opens += 1; + } + existing.state = state; + existing.failures = failures; + breakerMetrics.set(name, existing); +} + +export class CircuitBreaker { + private state: CircuitBreakerState = "CLOSED"; + private failureCount = 0; + private lastFailureTime = 0; + private readonly name: string; + + constructor( + private readonly failureThreshold = 5, + private readonly resetTimeoutMs = 30_000, + name?: string, + ) { + this.name = name ?? "circuit-breaker"; + recordCircuitBreakerState(this.name, this.state, this.failureCount); + } + + /** Allow string name as third arg or options object for forward compatibility */ + static fromOptions(opts: CircuitBreakerOptions = {}): CircuitBreaker { + return new CircuitBreaker(opts.failureThreshold ?? 5, opts.resetTimeoutMs ?? 30_000, opts.name); + } + + getState(): CircuitBreakerState { + return this.state; + } + + getFailureCount(): number { + return this.failureCount; + } + + getName(): string { + return this.name; + } + + async call(fn: () => Promise): Promise { + if (this.state === "OPEN") { + const elapsed = Date.now() - this.lastFailureTime; + if (elapsed >= this.resetTimeoutMs) { + logger.info({ breaker: this.name }, "Circuit breaker transitioning to HALF_OPEN"); + this.state = "HALF_OPEN"; + recordCircuitBreakerState(this.name, this.state, this.failureCount); + } else { + // Preserve X API wording for backwards compat; generic name still satisfies substring check "circuit breaker is open" + const message = this.name === "X API" + ? "X API circuit breaker is open - too many failures" + : `${this.name} circuit breaker is open - too many failures`; + throw new ServiceUnavailableError(message); + } + } + + try { + const result = await fn(); + if (this.state === "HALF_OPEN") { + logger.info({ breaker: this.name }, "Circuit breaker reset to CLOSED after successful call"); + this.reset(); + } else { + this.failureCount = 0; + this.lastFailureTime = 0; + recordCircuitBreakerState(this.name, this.state, this.failureCount); + } + return result; + } catch (error) { + this.failureCount++; + this.lastFailureTime = Date.now(); + if (this.failureCount >= this.failureThreshold) { + logger.warn( + { breaker: this.name, failureCount: this.failureCount }, + "Circuit breaker OPEN - too many failures", + ); + this.state = "OPEN"; + recordCircuitBreakerState(this.name, this.state, this.failureCount); + } else { + recordCircuitBreakerState(this.name, this.state, this.failureCount); + } + throw error; + } + } + + reset(): void { + this.state = "CLOSED"; + this.failureCount = 0; + this.lastFailureTime = 0; + recordCircuitBreakerState(this.name, this.state, this.failureCount); + } +} + +// Default X breaker for re-export compatibility - must match original defaults (5, 30_000) +export const xCircuitBreaker = new CircuitBreaker(5, 30_000, "X API"); + +// Pre-configured breakers for RPC and Horizon (issue #091) +export function createRpcCircuitBreaker(threshold = 5, resetMs = 30_000): CircuitBreaker { + return new CircuitBreaker(threshold, resetMs, "Soroban RPC"); +} +export function createHorizonCircuitBreaker(threshold = 5, resetMs = 30_000): CircuitBreaker { + return new CircuitBreaker(threshold, resetMs, "Horizon"); +} diff --git a/backend/src/common/utils/fetchWithTimeout.ts b/backend/src/common/utils/fetchWithTimeout.ts new file mode 100644 index 00000000..3a9fdd59 --- /dev/null +++ b/backend/src/common/utils/fetchWithTimeout.ts @@ -0,0 +1,139 @@ +import { logger } from "./logger.js"; + +/** + * Timeout-aware fetch wrapper (issue #090). + * - Merges an explicit timeout (AbortSignal.timeout) with an optional parent AbortSignal (client disconnect). + * - Throws a TimeoutError-named DOMException on timeout so callers can distinguish upstream timeout from cancellation. + */ + +export interface FetchWithTimeoutOptions extends RequestInit { + timeoutMs?: number; + /** Parent signal (e.g. from Express req.signal) — aborting this aborts the fetch. */ + parentSignal?: AbortSignal; +} + +/** + * Performs a fetch with an explicit timeout and optional parent signal. + * Uses AbortSignal.any when available to combine signals, otherwise manual AbortController. + */ +export async function fetchWithTimeout( + url: string, + options: FetchWithTimeoutOptions = {}, +): Promise { + const { timeoutMs, parentSignal, signal: explicitSignal, ...rest } = options; + + // Build timeout signal if requested + const timeoutSignal = timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined; + + // Merge signals: parentSignal + timeoutSignal + explicitSignal + let combinedSignal: AbortSignal | undefined; + const signals: AbortSignal[] = []; + if (parentSignal) signals.push(parentSignal); + if (timeoutSignal) signals.push(timeoutSignal); + if (explicitSignal) signals.push(explicitSignal as AbortSignal); + + if (signals.length === 0) { + combinedSignal = undefined; + } else if (signals.length === 1) { + combinedSignal = signals[0]; + } else { + // Node 20+ supports AbortSignal.any; fallback to manual controller + const anyFn = (AbortSignal as unknown as { any?: (s: AbortSignal[]) => AbortSignal }).any; + if (typeof anyFn === "function") { + combinedSignal = anyFn.call(AbortSignal, signals); + } else { + const controller = new AbortController(); + const onAbort = () => controller.abort((signals.find((s) => s.aborted)?.reason as Error) ?? new DOMException("Aborted", "AbortError")); + for (const s of signals) { + if (s.aborted) { + onAbort(); + break; + } + s.addEventListener("abort", onAbort, { once: true }); + } + combinedSignal = controller.signal; + } + } + + try { + return await fetch(url, { ...rest, signal: combinedSignal }); + } catch (err) { + // Normalise timeout vs cancellation for logging + if (err instanceof DOMException) { + if (err.name === "TimeoutError") { + logger.warn({ url, timeoutMs }, "Upstream request timed out"); + // Preserve TimeoutError name so retry logic can treat it as transient + throw err; + } + if (err.name === "AbortError") { + // Could be client disconnect — check parentSignal + if (parentSignal?.aborted) { + logger.debug({ url }, "Upstream request aborted due to client disconnect"); + } + throw err; + } + } + throw err; + } +} + +/** + * Wraps any promise with a timeout. Used for Soroban RPC calls that don't go through fetch directly. + */ +export async function withTimeout(promise: Promise, timeoutMs: number, operation = "operation"): Promise { + let timeoutId: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + const err = new DOMException(`${operation} timed out after ${timeoutMs}ms`, "TimeoutError"); + reject(err); + }, timeoutMs); + }); + try { + const result = await Promise.race([promise, timeoutPromise]); + return result as T; + } finally { + if (timeoutId) clearTimeout(timeoutId); + } +} + +/** + * Wraps a promise with both timeout and parent signal cancellation. + */ +export async function withTimeoutAndSignal( + promise: Promise, + timeoutMs: number, + parentSignal?: AbortSignal, + operation = "operation", +): Promise { + if (parentSignal?.aborted) { + throw new DOMException("Aborted due to client disconnect", "AbortError"); + } + let timeoutId: ReturnType | undefined; + let onAbort: (() => void) | undefined; + + const abortPromise = parentSignal + ? new Promise((_, reject) => { + onAbort = () => reject(new DOMException("Aborted due to client disconnect", "AbortError")); + parentSignal.addEventListener("abort", onAbort, { once: true }); + }) + : null; + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + const err = new DOMException(`${operation} timed out after ${timeoutMs}ms`, "TimeoutError"); + reject(err); + }, timeoutMs); + }); + + const race: Promise[] = [promise as Promise]; + // Trick: push timeout and abort as Promise via type coercion + (race as unknown as Promise[]).push(timeoutPromise as unknown as Promise); + if (abortPromise) (race as unknown as Promise[]).push(abortPromise as unknown as Promise); + + try { + return await Promise.race(race); + } finally { + if (timeoutId) clearTimeout(timeoutId); + if (parentSignal && onAbort) parentSignal.removeEventListener("abort", onAbort); + } +} diff --git a/backend/src/common/utils/retry.ts b/backend/src/common/utils/retry.ts new file mode 100644 index 00000000..0bee2c24 --- /dev/null +++ b/backend/src/common/utils/retry.ts @@ -0,0 +1,165 @@ +import { logger } from "./logger.js"; +import { config } from "../../config/index.js"; + +/** + * Shared retry utility with exponential backoff and full jitter (issues #092, #090). + * - Only idempotent/transient failures retry — never a 4xx (except 429/408), never a non-idempotent write without idempotency key. + * - Max attempts and ceiling are configurable via env or per-call options. + * - Retry attempts are logged with reason. + */ + +export interface RetryOptions { + maxAttempts?: number; + initialDelayMs?: number; + maxDelayMs?: number; + factor?: number; + jitter?: boolean; + /** HTTP method for idempotency check — when set, non-idempotent writes (POST/PATCH) only retry if idempotencyKey is present. */ + method?: string; + /** If present, POST/PATCH are considered safe to retry (compose with #075). */ + idempotencyKey?: string; + /** Override transient check */ + isRetryable?: (error: unknown) => boolean; + /** AbortSignal to support cancellation (issue #090) */ + signal?: AbortSignal; +} + +const IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS", "TRACE"]); + +/** + * Determines if an error is transient and should be retried. + * - Network/timeout/connection errors, 429, 502, 503, 504, 408. + * - Never retry 4xx client errors except 429/408. + */ +export function isTransientError(error: unknown): boolean { + if (error instanceof DOMException) { + if (error.name === "TimeoutError") return true; + if (error.name === "AbortError") return false; + } + if (error instanceof Error) { + // Check for AbortError with message timeout + if (error.name === "AbortError" || error.name === "TimeoutError") return true; + const message = error.message.toLowerCase(); + if ( + message.includes("timeout") || + message.includes("timed out") || + message.includes("network") || + message.includes("connection") || + message.includes("econnrefused") || + message.includes("econnreset") || + message.includes("socket hang up") + ) { + return true; + } + // Status-based check — look for .status, .statusCode, .code + const maybe = error as unknown as { status?: number; statusCode?: number; code?: string | number }; + const status = maybe.status ?? maybe.statusCode ?? (typeof maybe.code === "number" ? maybe.code : undefined); + if (status !== undefined) { + const code = Number(status); + if (code === 429 || code === 408 || code === 502 || code === 503 || code === 504) return true; + if (code >= 400 && code < 500) return false; + if (code >= 500) return true; + } + // Message heuristics for status text + if ( + message.includes("rate limit") || + message.includes("too many requests") || + message.includes("service unavailable") || + message.includes("bad gateway") || + message.includes("gateway timeout") || + message.includes("internal server error") + ) { + return true; + } + } + return false; +} + +function isIdempotent(method: string | undefined, idempotencyKey: string | undefined): boolean { + if (!method) return true; // if no method context, assume safe (indexer retries are safe) + const upper = method.toUpperCase(); + if (IDEMPOTENT_METHODS.has(upper)) return true; + // POST/PATCH with idempotency key are safe (issue #075) + if (idempotencyKey && idempotencyKey.trim().length > 0) return true; + return false; +} + +function computeDelay(attempt: number, initialDelayMs: number, maxDelayMs: number, factor: number, jitter: boolean): number { + const exponential = Math.min(initialDelayMs * Math.pow(factor, attempt - 1), maxDelayMs); + if (jitter) { + // Full jitter: random [0, exponential] + return Math.random() * exponential; + } + return exponential; +} + +export async function withRetry(fn: () => Promise, options: RetryOptions = {}): Promise { + const { + maxAttempts = (config as unknown as { retry?: { maxAttempts: number } })?.retry?.maxAttempts ?? 3, + initialDelayMs = (config as unknown as { retry?: { initialDelayMs: number } })?.retry?.initialDelayMs ?? 100, + maxDelayMs = (config as unknown as { retry?: { maxDelayMs: number } })?.retry?.maxDelayMs ?? 5000, + factor = (config as unknown as { retry?: { factor: number } })?.retry?.factor ?? 2, + jitter = true, + method, + idempotencyKey, + isRetryable = isTransientError, + signal, + } = options; + + let attempt = 0; + + while (true) { + if (signal?.aborted) { + throw new DOMException("Retry aborted", "AbortError"); + } + try { + return await fn(); + } catch (error) { + attempt++; + + // Never retry if not idempotent + if (!isIdempotent(method, idempotencyKey)) { + logger.debug({ attempt, method, reason: "non-idempotent" }, "Not retrying non-idempotent operation"); + throw error; + } + + // Never retry if not transient + if (!isRetryable(error)) { + logger.debug({ attempt, error: (error as Error)?.message, reason: "non-retryable" }, "Not retrying non-retryable error"); + throw error; + } + + if (attempt >= maxAttempts) { + logger.warn({ attempt, maxAttempts, error: (error as Error)?.message }, "Retry exhausted"); + throw error; + } + + const delay = computeDelay(attempt, initialDelayMs, maxDelayMs, factor, jitter); + const errMsg = error instanceof Error ? error.message : String(error); + logger.warn( + { attempt, maxAttempts, delay: Math.round(delay), error: errMsg }, + `Retrying after transient error (attempt ${attempt}/${maxAttempts})`, + ); + + // Support cancellation while waiting + if (signal) { + await new Promise((resolve, reject) => { + const timeout = setTimeout(resolve, delay); + const onAbort = () => { + clearTimeout(timeout); + reject(new DOMException("Retry aborted", "AbortError")); + }; + signal.addEventListener("abort", onAbort, { once: true }); + // also handle timeout cleanup + setTimeout(() => signal.removeEventListener("abort", onAbort), delay + 10); + }); + if (signal.aborted) throw new DOMException("Retry aborted", "AbortError"); + } else { + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + } +} + +/** Legacy export alias for indexer compatibility */ +export const retry = withRetry; diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 0c7493f9..ed6a3231 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -186,6 +186,33 @@ export const envSchema = z.object({ OG_IMAGE_CACHE_TTL_SECONDS: z.coerce.number().int().positive().default(86400), OG_IMAGE_CONCURRENCY: z.coerce.number().int().positive().default(4), + // ── Outbound & server timeouts (issue #090) ───────────────────────────── + SOROBAN_RPC_TIMEOUT_MS: z.coerce.number().int().positive().default(10_000), + HORIZON_TIMEOUT_MS: z.coerce.number().int().positive().default(8_000), + IPFS_TIMEOUT_MS: z.coerce.number().int().positive().default(15_000), + X_API_TIMEOUT_MS: z.coerce.number().int().positive().default(10_000), + REQUEST_TIMEOUT_MS: z.coerce.number().int().positive().default(30_000), + + // ── Circuit breaker (issue #091) ─────────────────────────────────────── + CIRCUIT_BREAKER_THRESHOLD: z.coerce.number().int().positive().default(5), + CIRCUIT_BREAKER_RESET_TIMEOUT_MS: z.coerce.number().int().positive().default(30_000), + RPC_CIRCUIT_BREAKER_THRESHOLD: z.coerce.number().int().positive().default(5), + RPC_CIRCUIT_BREAKER_RESET_TIMEOUT_MS: z.coerce.number().int().positive().default(30_000), + HORIZON_CIRCUIT_BREAKER_THRESHOLD: z.coerce.number().int().positive().default(5), + HORIZON_CIRCUIT_BREAKER_RESET_TIMEOUT_MS: z.coerce.number().int().positive().default(30_000), + + // ── Retry with jitter (issue #092) ──────────────────────────────────── + RETRY_MAX_ATTEMPTS: z.coerce.number().int().positive().default(3), + RETRY_INITIAL_DELAY_MS: z.coerce.number().int().positive().default(100), + RETRY_MAX_DELAY_MS: z.coerce.number().int().positive().default(5_000), + RETRY_FACTOR: z.coerce.number().positive().default(2), + + // ── Payload limits (issue #077) ─────────────────────────────────────── + JSON_BODY_LIMIT: z.string().default('100kb'), + MULTER_FILE_SIZE_LIMIT: z.coerce.number().int().positive().default(5 * 1024 * 1024), + MULTER_FILES_LIMIT: z.coerce.number().int().positive().default(1), + MULTER_FIELDS_LIMIT: z.coerce.number().int().positive().default(10), + LOG_LEVEL: z.string().default('info'), SENTRY_DSN: z.string().optional(), }) diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 0eaee232..83e40e26 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -115,6 +115,37 @@ export const config = { concurrency: env.OG_IMAGE_CONCURRENCY, }, + timeouts: { + sorobanRpcMs: env.SOROBAN_RPC_TIMEOUT_MS, + horizonMs: env.HORIZON_TIMEOUT_MS, + ipfsMs: env.IPFS_TIMEOUT_MS, + xApiMs: env.X_API_TIMEOUT_MS, + requestMs: env.REQUEST_TIMEOUT_MS, + }, + + circuitBreaker: { + threshold: env.CIRCUIT_BREAKER_THRESHOLD, + resetTimeoutMs: env.CIRCUIT_BREAKER_RESET_TIMEOUT_MS, + rpcThreshold: env.RPC_CIRCUIT_BREAKER_THRESHOLD, + rpcResetTimeoutMs: env.RPC_CIRCUIT_BREAKER_RESET_TIMEOUT_MS, + horizonThreshold: env.HORIZON_CIRCUIT_BREAKER_THRESHOLD, + horizonResetTimeoutMs: env.HORIZON_CIRCUIT_BREAKER_RESET_TIMEOUT_MS, + }, + + retry: { + maxAttempts: env.RETRY_MAX_ATTEMPTS, + initialDelayMs: env.RETRY_INITIAL_DELAY_MS, + maxDelayMs: env.RETRY_MAX_DELAY_MS, + factor: env.RETRY_FACTOR, + }, + + payload: { + jsonLimit: env.JSON_BODY_LIMIT, + multerFileSize: env.MULTER_FILE_SIZE_LIMIT, + multerFiles: env.MULTER_FILES_LIMIT, + multerFields: env.MULTER_FIELDS_LIMIT, + }, + logging: { level: env.LOG_LEVEL, sentryDsn: env.SENTRY_DSN, diff --git a/backend/src/indexer/retry.ts b/backend/src/indexer/retry.ts index e0ee92ef..d71abb97 100644 --- a/backend/src/indexer/retry.ts +++ b/backend/src/indexer/retry.ts @@ -1,56 +1,23 @@ /** - * Executes a function with exponential backoff retry logic. - * Used for transient RPC errors that may occur during indexing. + * Re-export shared retry utility — standardised behaviour (issue #092). + * Keeps this file as the import surface for the indexer so existing imports keep working. + * The shared implementation lives in src/common/utils/retry.ts with full jitter, + * proper 4xx isolation and method/idempotency awareness. + * + * Backward-compat: default maxAttempts stays 5 (original indexer default) when + * caller omits options, matching existing tests. New callers should import from + * 'common/utils/retry.js' and rely on env-configured defaults (RETRY_MAX_ATTEMPTS=3). */ -export interface RetryOptions { - maxAttempts?: number; - initialDelayMs?: number; - maxDelayMs?: number; - factor?: number; -} +export type { RetryOptions } from "../common/utils/retry.js"; +export { isTransientError } from "../common/utils/retry.js"; +import { withRetry as sharedRetry } from "../common/utils/retry.js"; +import type { RetryOptions } from "../common/utils/retry.js"; -/** Returns true for transient errors that should be retried. */ -function isTransientError(error: unknown): boolean { - if (error instanceof Error) { - const message = error.message.toLowerCase(); - return ( - message.includes('timeout') || - message.includes('network') || - message.includes('connection') || - message.includes('rate limit') || - message.includes('too many requests') || - message.includes('service unavailable') || - message.includes('internal server error') || - 'status' in error && [429, 502, 503, 504].includes(Number((error as { status?: number }).status)) - ); - } - return false; -} - -/** - * Executes a function with exponential backoff retry logic. - * Retries on transient errors (network timeouts, rate limits, 5xx status codes). - */ export async function withRetry(fn: () => Promise, options: RetryOptions = {}): Promise { - const { - maxAttempts = 5, - initialDelayMs = 100, - maxDelayMs = 5000, - factor = 2, - } = options; - - let attempt = 0; - - while (true) { - try { - return await fn(); - } catch (error) { - attempt++; - if (attempt >= maxAttempts || !isTransientError(error)) { - throw error; - } - const delay = Math.min(initialDelayMs * factor ** (attempt - 1), maxDelayMs); - await new Promise((resolve) => setTimeout(resolve, delay)); - } - } + // Preserve indexer legacy default (5) when maxAttempts unspecified; shared default is env-driven (3) + const opts: RetryOptions = { maxAttempts: 5, ...options }; + // For indexer internal retries, disable jitter by default to keep existing timer tests deterministic + // (callers can explicitly enable jitter via { jitter: true }) + if (opts.jitter === undefined) opts.jitter = false; + return sharedRetry(fn, opts); } \ No newline at end of file diff --git a/backend/src/modules/admin/config.service.ts b/backend/src/modules/admin/config.service.ts index 4b88fb45..01ea0efd 100644 --- a/backend/src/modules/admin/config.service.ts +++ b/backend/src/modules/admin/config.service.ts @@ -30,6 +30,7 @@ import { } from '@stellar/stellar-sdk'; import { config } from '../../config/index.js'; import { logger } from '../../common/utils/logger.js'; +import { rpcCall } from '../../common/stellar/rpcClient.js'; import { BadRequestError, NotFoundError } from '../../common/errors/AppError.js'; import { logAuditAction } from './admin.service.js'; import type { @@ -71,9 +72,10 @@ async function buildUnsignedConfigTx( ): Promise { const contractId = getContractId(); const networkPassphrase = getNetworkPassphrase(); - const server = getRpcServer(); - const sourceAccount = await server.getAccount(adminAddress).catch(() => { + const sourceAccount = await rpcCall((server) => server.getAccount(adminAddress), { + operationName: 'getAccount', + }).catch(() => { throw new BadRequestError('Admin Stellar account not found on network'); }); @@ -86,7 +88,9 @@ async function buildUnsignedConfigTx( .setTimeout(30) .build(); - const sim = await server.simulateTransaction(tx).catch((err: Error) => { + const sim = await rpcCall((server) => server.simulateTransaction(tx), { + operationName: 'simulateTransaction', + }).catch((err: Error) => { logger.error({ err, contractFn }, 'Config tx simulation failed'); throw new BadRequestError('Transaction simulation failed'); }); @@ -112,7 +116,6 @@ async function broadcastSignedTx( after: Record, ): Promise { const networkPassphrase = getNetworkPassphrase(); - const server = getRpcServer(); // Parse the XDR first so we get a clear error before hitting the network. let tx; @@ -132,7 +135,9 @@ async function broadcastSignedTx( let status: SubmittedConfigTx['status'] = 'ERROR'; try { - const send = await server.sendTransaction(tx); + const send = await rpcCall((server) => server.sendTransaction(tx), { + operationName: 'sendTransaction', + }); txHash = send.hash; status = send.status === 'ERROR' ? 'ERROR' : 'PENDING'; if (send.status === 'ERROR') { @@ -219,16 +224,18 @@ export async function submitSetFee( * The data shape matches what `propose_fee_change_inner` stores on-chain: * (fee_bps: u32, effective_ledger: u32, proposed_ledger: u32, is_decrease: bool) */ -export async function getPendingFeeChange(): Promise { +export async function getPendingFeeChange(opts: { signal?: AbortSignal } = {}): Promise { const contractId = getContractId(); - const server = getRpcServer(); try { // Query the contract's `get_pending_fee_change` view function. const contract = new Contract(contractId); const tx = new TransactionBuilder( // Use a placeholder account; we only need to simulate. - await server.getAccount(contract.address()).catch(async () => { + await rpcCall((server) => server.getAccount(contract.address()), { + signal: opts.signal, + operationName: 'getAccount', + }).catch(async () => { // If contract address isn't a valid Stellar account, simulate with a different source. throw new BadRequestError('Cannot query pending fee change: RPC unavailable'); }), @@ -238,7 +245,10 @@ export async function getPendingFeeChange(): Promise { .setTimeout(30) .build(); - const sim = await server.simulateTransaction(tx); + const sim = await rpcCall((server) => server.simulateTransaction(tx), { + signal: opts.signal, + operationName: 'simulateTransaction', + }); if (SorobanRpc.Api.isSimulationError(sim)) { // No pending fee change returns a specific error from the contract. return null; diff --git a/backend/src/modules/health/health.routes.ts b/backend/src/modules/health/health.routes.ts index f63949da..cb1e58fa 100644 --- a/backend/src/modules/health/health.routes.ts +++ b/backend/src/modules/health/health.routes.ts @@ -1,6 +1,4 @@ -import { SorobanRpc } from '@stellar/stellar-sdk'; import { Router } from 'express'; -import { config } from '../../config/index.js'; import { prisma } from '../../db/prisma.js'; import { redis } from '../../db/redis.js'; import { @@ -8,6 +6,7 @@ import { type HealthDependencies, type HealthService, } from './health.service.js'; +import { rpcCall } from '../../common/stellar/rpcClient.js'; const dependencies: HealthDependencies = { postgres: async () => { @@ -17,11 +16,10 @@ const dependencies: HealthDependencies = { await redis.ping(); }, 'soroban-rpc': async () => { - const server = new SorobanRpc.Server(config.stellar.rpcUrl, { - allowHttp: config.stellar.rpcUrl.startsWith('http://'), + const health = await rpcCall((server) => server.getHealth(), { + operationName: 'getHealth', }); - const health = await server.getHealth(); - if (health.status !== 'healthy') throw new Error('Soroban RPC reported an unhealthy status'); + if ((health as { status: string }).status !== 'healthy') throw new Error('Soroban RPC reported an unhealthy status'); }, }; diff --git a/backend/src/modules/ipfs/ipfs.controller.ts b/backend/src/modules/ipfs/ipfs.controller.ts index 8096511c..cd4ea3bd 100644 --- a/backend/src/modules/ipfs/ipfs.controller.ts +++ b/backend/src/modules/ipfs/ipfs.controller.ts @@ -22,12 +22,15 @@ export async function uploadImageController( ); } - const result = await pinImageToIpfs({ - buffer: file.buffer, - mimetype: file.mimetype, - size: file.size, - originalname: file.originalname, - }); + const result = await pinImageToIpfs( + { + buffer: file.buffer, + mimetype: file.mimetype, + size: file.size, + originalname: file.originalname, + }, + { signal: req.signal }, + ); res.status(201).json({ status: "success", diff --git a/backend/src/modules/ipfs/ipfs.routes.ts b/backend/src/modules/ipfs/ipfs.routes.ts index a3397aeb..c1dcd5d8 100644 --- a/backend/src/modules/ipfs/ipfs.routes.ts +++ b/backend/src/modules/ipfs/ipfs.routes.ts @@ -4,12 +4,20 @@ import { uploadImageController, getGatewayUrlController } from "./ipfs.controlle import { MAX_IMAGE_SIZE_BYTES } from "./ipfs.service.js"; /** - * Configure Multer in-memory storage with file size limits. + * Configure Multer in-memory storage with explicit limits (issue #077). + * - fileSize: 5 MB (MAX_IMAGE_SIZE_BYTES) — documented, tight + * - files: 1 — single image per request, disk-exhaustion guard (multer default is unlimited) + * - fields: 10 — generous for form metadata but bounded + * - file count enforced via fields([{maxCount:1}]) ; overall files limit is secondary guard + * Oversized payloads surface as MulterError LIMIT_FILE_SIZE/COUNT and are mapped to 413 PAYLOAD_TOO_LARGE. */ const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: MAX_IMAGE_SIZE_BYTES, + files: 1, + fields: 10, + fieldSize: 1024 * 1024, // 1 MB field size to avoid large non-file fields }, }); diff --git a/backend/src/modules/ipfs/ipfs.service.ts b/backend/src/modules/ipfs/ipfs.service.ts index 78d2618b..c638fa33 100644 --- a/backend/src/modules/ipfs/ipfs.service.ts +++ b/backend/src/modules/ipfs/ipfs.service.ts @@ -7,6 +7,7 @@ import { ServiceUnavailableError, } from "../../common/errors/AppError.js"; import { buildGatewayUrl } from "./ipfs.utils.js"; +import { fetchWithTimeout } from "../../common/utils/fetchWithTimeout.js"; import type { IpfsUploadResponse } from "./ipfs.types.js"; /** Default max file size limit for image uploads (5 MB) */ @@ -77,12 +78,15 @@ export function generateFallbackCid(buffer: Buffer): string { * @returns Object containing CID and resolvable gateway URL. * @throws BadRequestError, BadGatewayError, or ServiceUnavailableError. */ -export async function pinImageToIpfs(file: { - mimetype: string; - size: number; - buffer: Buffer; - originalname?: string; -}): Promise { +export async function pinImageToIpfs( + file: { + mimetype: string; + size: number; + buffer: Buffer; + originalname?: string; + }, + opts: { signal?: AbortSignal } = {}, +): Promise { // 1. Validate file format and constraints validateImageFile(file); @@ -101,16 +105,18 @@ export async function pinImageToIpfs(file: { }; } - // 3. Pin image via IPFS HTTP API endpoint + // 3. Pin image via IPFS HTTP API endpoint (explicit timeout + client disconnect — issue #090) try { const formData = new globalThis.FormData(); const blob = new globalThis.Blob([file.buffer], { type: file.mimetype }); formData.append("file", blob, file.originalname || "image"); const endpoint = `${ipfsApiUrl.replace(/\/+$/, "")}/api/v0/add?pin=true`; - const response = await globalThis.fetch(endpoint, { + const response = await fetchWithTimeout(endpoint, { method: "POST", body: formData, + timeoutMs: (config as unknown as { timeouts?: { ipfsMs: number } })?.timeouts?.ipfsMs ?? 15_000, + parentSignal: opts.signal, }); if (!response.ok) { @@ -121,7 +127,7 @@ export async function pinImageToIpfs(file: { ); // Fallback strategy (#984): if in dev/test, fallback gracefully; in prod throw BadGatewayError - if (config.server.nodeEnv !== "production") { + if ((config as unknown as { server?: { nodeEnv: string } })?.server?.nodeEnv !== "production") { logger.warn("Non-production environment: falling back after IPFS pinning HTTP error."); const fallbackCid = generateFallbackCid(file.buffer); return { @@ -157,10 +163,24 @@ export async function pinImageToIpfs(file: { throw error; } + // Timeout vs cancellation mapping (issue #090) + if (error instanceof DOMException && error.name === "TimeoutError") { + logger.warn({ endpoint: `${ipfsApiUrl}/api/v0/add`, timeoutMs: (config as unknown as { timeouts?: { ipfsMs: number } })?.timeouts?.ipfsMs ?? 15_000 }, "IPFS pinning timed out"); + if ((config as unknown as { server?: { nodeEnv: string } })?.server?.nodeEnv !== "production") { + const fallbackCid = generateFallbackCid(file.buffer); + return { cid: fallbackCid, url: buildGatewayUrl(fallbackCid), size: file.size, mimeType: file.mimetype }; + } + throw new ServiceUnavailableError(`IPFS pinning timed out after ${(config as unknown as { timeouts?: { ipfsMs: number } })?.timeouts?.ipfsMs ?? 15_000}ms`); + } + if (error instanceof DOMException && error.name === "AbortError") { + logger.debug("IPFS pinning aborted (client disconnect)"); + throw new ServiceUnavailableError("IPFS pinning cancelled"); + } + logger.error({ error }, "Error communicating with IPFS pinning service"); // Fallback handling (#984): network exception / timeout fallback for non-prod - if (config.server.nodeEnv !== "production") { + if ((config as unknown as { server?: { nodeEnv: string } })?.server?.nodeEnv !== "production") { logger.warn("Non-production environment: fallback CID generated after IPFS failure."); const fallbackCid = generateFallbackCid(file.buffer); return { diff --git a/backend/src/modules/refunds/refunds.service.ts b/backend/src/modules/refunds/refunds.service.ts index de73ce6e..320ba84a 100644 --- a/backend/src/modules/refunds/refunds.service.ts +++ b/backend/src/modules/refunds/refunds.service.ts @@ -14,6 +14,7 @@ import { NotFoundError, } from '../../common/errors/AppError.js'; import { logger } from '../../common/utils/logger.js'; +import { rpcCall } from '../../common/stellar/rpcClient.js'; import { handleUniqueConstraintViolation } from '../../common/utils/prisma-errors.js'; import type { Prisma } from '@prisma/client'; import { @@ -99,8 +100,9 @@ async function prepareRefundResolutionTx( const contractId = config.stellar.contractId; if (!contractId) throw new BadRequestError('Contract ID is not configured'); - const server = getServer(); - const sourceAccount = await server.getAccount(creatorAddress).catch(() => { + const sourceAccount = await rpcCall((server) => server.getAccount(creatorAddress), { + operationName: 'getAccount', + }).catch(() => { throw new BadRequestError('Source account not found on network'); }); const networkPassphrase = getNetworkPassphrase(); @@ -117,7 +119,9 @@ async function prepareRefundResolutionTx( .setTimeout(30) .build(); - const simulateResponse = await server.simulateTransaction(tx).catch((err: Error) => { + const simulateResponse = await rpcCall((server) => server.simulateTransaction(tx), { + operationName: 'simulateTransaction', + }).catch((err: Error) => { logger.error({ err, method }, 'Refund resolution simulation failed'); throw new BadRequestError('Transaction simulation failed'); }); @@ -146,8 +150,9 @@ async function submitRefundResolutionTx( const networkPassphrase = getNetworkPassphrase(); const tx = TransactionBuilder.fromXDR(signedTxXdr, networkPassphrase); - const server = getServer(); - const sendResponse = await server.sendTransaction(tx).catch((err: Error) => { + const sendResponse = await rpcCall((server) => server.sendTransaction(tx), { + operationName: 'sendTransaction', + }).catch((err: Error) => { logger.error({ err, refundId, status }, 'Refund resolution submission failed'); throw new BadRequestError('Failed to submit refund transaction'); }); diff --git a/backend/src/modules/subscriptions/subscriptions.service.ts b/backend/src/modules/subscriptions/subscriptions.service.ts index 24e88cd8..266aa1da 100644 --- a/backend/src/modules/subscriptions/subscriptions.service.ts +++ b/backend/src/modules/subscriptions/subscriptions.service.ts @@ -4,6 +4,7 @@ import { prisma } from '../../db/prisma.js'; import type { Prisma } from '@prisma/client'; import { BadRequestError, NotFoundError } from '../../common/errors/AppError.js'; import { logger } from '../../common/utils/logger.js'; +import { rpcCall } from '../../common/stellar/rpcClient.js'; import type { SubscriptionResponse, PreparedSubscriptionTx, @@ -136,8 +137,9 @@ export async function prepareCreateSubscription( const parsedAmount = BigInt(amountStroops); if (parsedAmount <= 0) throw new BadRequestError('Amount must be positive'); - const server = getServer(); - const sourceAccount = await server.getAccount(tipper.stellarAddress).catch(() => { + const sourceAccount = await rpcCall((server) => server.getAccount(tipper.stellarAddress), { + operationName: 'getAccount', + }).catch(() => { throw new BadRequestError('Source account not found on network'); }); const networkPassphrase = getNetworkPassphrase(); @@ -156,7 +158,9 @@ export async function prepareCreateSubscription( .setTimeout(30) .build(); - const simulateResponse = await server.simulateTransaction(tx).catch((err: Error) => { + const simulateResponse = await rpcCall((server) => server.simulateTransaction(tx), { + operationName: 'simulateTransaction', + }).catch((err: Error) => { logger.error({ err }, 'Subscription creation simulation failed'); throw new BadRequestError('Transaction simulation failed'); }); @@ -199,8 +203,9 @@ export async function submitCreateSubscription( const networkPassphrase = getNetworkPassphrase(); const tx = TransactionBuilder.fromXDR(signedTxXdr, networkPassphrase); - const server = getServer(); - const sendResponse = await server.sendTransaction(tx).catch((err: Error) => { + const sendResponse = await rpcCall((server) => server.sendTransaction(tx), { + operationName: 'sendTransaction', + }).catch((err: Error) => { logger.error({ err }, 'Subscription creation submission failed'); throw new BadRequestError('Failed to submit subscription transaction'); }); @@ -271,8 +276,9 @@ export async function prepareCancelSubscription( await loadOwnedActiveSubscription(tipperId, creatorStellarAddress); - const server = getServer(); - const sourceAccount = await server.getAccount(tipper.stellarAddress).catch(() => { + const sourceAccount = await rpcCall((server) => server.getAccount(tipper.stellarAddress), { + operationName: 'getAccount', + }).catch(() => { throw new BadRequestError('Source account not found on network'); }); const networkPassphrase = getNetworkPassphrase(); @@ -289,7 +295,9 @@ export async function prepareCancelSubscription( .setTimeout(30) .build(); - const simulateResponse = await server.simulateTransaction(tx).catch((err: Error) => { + const simulateResponse = await rpcCall((server) => server.simulateTransaction(tx), { + operationName: 'simulateTransaction', + }).catch((err: Error) => { logger.error({ err }, 'Subscription cancellation simulation failed'); throw new BadRequestError('Transaction simulation failed'); }); @@ -322,8 +330,9 @@ export async function submitCancelSubscription( const networkPassphrase = getNetworkPassphrase(); const tx = TransactionBuilder.fromXDR(signedTxXdr, networkPassphrase); - const server = getServer(); - const sendResponse = await server.sendTransaction(tx).catch((err: Error) => { + const sendResponse = await rpcCall((server) => server.sendTransaction(tx), { + operationName: 'sendTransaction', + }).catch((err: Error) => { logger.error({ err }, 'Subscription cancellation submission failed'); throw new BadRequestError('Failed to submit cancellation transaction'); }); @@ -363,10 +372,11 @@ export async function chargeSubscriptionOnChain( if (!keeperSecretKey) throw new Error('Subscription keeper secret key is not configured'); const keeperKeypair = Keypair.fromSecret(keeperSecretKey); - const server = getServer(); const networkPassphrase = getNetworkPassphrase(); - const keeperAccount = await server.getAccount(keeperKeypair.publicKey()); + const keeperAccount = await rpcCall((server) => server.getAccount(keeperKeypair.publicKey()), { + operationName: 'getAccount', + }); const contract = new Contract(contractId); const tx = new TransactionBuilder(keeperAccount, { fee: '100', networkPassphrase }) @@ -380,7 +390,9 @@ export async function chargeSubscriptionOnChain( .setTimeout(30) .build(); - const simulateResponse = await server.simulateTransaction(tx); + const simulateResponse = await rpcCall((server) => server.simulateTransaction(tx), { + operationName: 'simulateTransaction', + }); if (SorobanRpc.Api.isSimulationError(simulateResponse)) { throw new Error(`Simulation error: ${simulateResponse.error}`); } @@ -388,7 +400,9 @@ export async function chargeSubscriptionOnChain( const prepared = SorobanRpc.assembleTransaction(tx, simulateResponse).build(); prepared.sign(keeperKeypair); - const sendResponse = await server.sendTransaction(prepared); + const sendResponse = await rpcCall((server) => server.sendTransaction(prepared), { + operationName: 'sendTransaction', + }); if (sendResponse.status === 'ERROR') { throw new Error('Subscription charge transaction rejected by the network'); } diff --git a/backend/src/modules/tips/tips.service.ts b/backend/src/modules/tips/tips.service.ts index 7ddb03de..3c9234bd 100644 --- a/backend/src/modules/tips/tips.service.ts +++ b/backend/src/modules/tips/tips.service.ts @@ -4,6 +4,7 @@ import { config } from '../../config/index.js'; import { prisma } from '../../db/prisma.js'; import { BadRequestError, NotFoundError } from '../../common/errors/AppError.js'; import { logger } from '../../common/utils/logger.js'; +import { rpcCall } from '../../common/stellar/rpcClient.js'; import { TipStatus } from '../../types/enums.js'; import * as notificationsService from '../notifications/notifications.service.js'; import { updateStreakOnTip } from '../streaks/streaks.service.js'; @@ -106,6 +107,7 @@ export async function prepareTip( to: string, amount: string, message?: string, + opts: { signal?: AbortSignal } = {}, ): Promise { const contractId = config.stellar.contractId; if (!contractId) { @@ -118,11 +120,10 @@ export async function prepareTip( throw new BadRequestError('Recipient not found'); } - const server = new SorobanRpc.Server(config.stellar.rpcUrl, { - allowHttp: config.stellar.rpcUrl.startsWith('http://'), - }); - - const sourceAccount = await server.getAccount(from).catch(() => { + const sourceAccount = await rpcCall( + (server) => server.getAccount(from), + { signal: opts.signal, operationName: 'getAccount' }, + ).catch(() => { throw new BadRequestError('Source account not found on network'); }); @@ -146,7 +147,10 @@ export async function prepareTip( .setTimeout(30) .build(); - const simulateResponse = await server.simulateTransaction(tx).catch((err: Error) => { + const simulateResponse = await rpcCall( + (server) => server.simulateTransaction(tx), + { signal: opts.signal, operationName: 'simulateTransaction' }, + ).catch((err: Error) => { logger.error({ err }, 'Transaction simulation failed'); throw new BadRequestError('Transaction simulation failed'); }); diff --git a/backend/src/modules/withdrawals/payoutSubmission.ts b/backend/src/modules/withdrawals/payoutSubmission.ts index 96b4fdba..5ea63e96 100644 --- a/backend/src/modules/withdrawals/payoutSubmission.ts +++ b/backend/src/modules/withdrawals/payoutSubmission.ts @@ -9,6 +9,7 @@ import { import { config } from '../../config/index.js'; import { logger } from '../../common/utils/logger.js'; import { BadRequestError } from '../../common/errors/AppError.js'; +import { rpcCall } from '../../common/stellar/rpcClient.js'; export interface ScheduledWithdrawalResult { txHash: string; @@ -41,10 +42,9 @@ export async function submitScheduledWithdrawal( } const keeper = Keypair.fromSecret(secret); - const server = new SorobanRpc.Server(config.stellar.rpcUrl, { - allowHttp: config.stellar.rpcUrl.startsWith('http://'), + const source = await rpcCall((server) => server.getAccount(keeper.publicKey()), { + operationName: 'getAccount', }); - const source = await server.getAccount(keeper.publicKey()); const networkPassphrase = Networks[config.stellar.network as keyof typeof Networks] ?? config.stellar.networkPassphrase; @@ -66,7 +66,9 @@ export async function submitScheduledWithdrawal( tx.sign(keeper); - const send = await server.sendTransaction(tx); + const send = await rpcCall((server) => server.sendTransaction(tx), { + operationName: 'sendTransaction', + }); if (send.status === 'ERROR') { logger.error({ creatorAddress, hash: send.hash }, 'Scheduled withdrawal rejected by network'); throw new BadRequestError('Scheduled withdrawal transaction rejected by the network'); diff --git a/backend/src/modules/withdrawals/withdrawals.service.ts b/backend/src/modules/withdrawals/withdrawals.service.ts index 92ff036a..38881c7b 100644 --- a/backend/src/modules/withdrawals/withdrawals.service.ts +++ b/backend/src/modules/withdrawals/withdrawals.service.ts @@ -4,6 +4,7 @@ import { config } from '../../config/index.js'; import { prisma } from '../../db/prisma.js'; import { BadRequestError } from '../../common/errors/AppError.js'; import { logger } from '../../common/utils/logger.js'; +import { rpcCall } from '../../common/stellar/rpcClient.js'; import type { WithdrawalResponse, WithdrawableBalanceResponse, @@ -113,6 +114,7 @@ export interface PreparedWithdrawal { export async function prepareWithdrawal( userId: string, amount: string, + opts: { signal?: AbortSignal } = {}, ): Promise { const contractId = config.stellar.contractId; if (!contractId) { @@ -133,11 +135,10 @@ export async function prepareWithdrawal( const { fee, netAmount } = calculateWithdrawalFee(parsedAmount); - const server = new SorobanRpc.Server(config.stellar.rpcUrl, { - allowHttp: config.stellar.rpcUrl.startsWith('http://'), - }); - - const sourceAccount = await server.getAccount(user.stellarAddress).catch(() => { + const sourceAccount = await rpcCall((server) => server.getAccount(user.stellarAddress), { + signal: opts.signal, + operationName: 'getAccount', + }).catch(() => { throw new BadRequestError('Source account not found on network'); }); const networkPassphrase = @@ -158,7 +159,10 @@ export async function prepareWithdrawal( .setTimeout(30) .build(); - const simulateResponse = await server.simulateTransaction(tx).catch((err: Error) => { + const simulateResponse = await rpcCall((server) => server.simulateTransaction(tx), { + signal: opts.signal, + operationName: 'simulateTransaction', + }).catch((err: Error) => { logger.error({ err }, 'Transaction simulation failed'); throw new BadRequestError('Transaction simulation failed'); }); @@ -202,6 +206,7 @@ export async function submitWithdrawal( userId: string, amount: string, signedTxXdr: string, + opts: { signal?: AbortSignal } = {}, ): Promise { const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new BadRequestError('User not found'); @@ -221,11 +226,10 @@ export async function submitWithdrawal( Networks[config.stellar.network as keyof typeof Networks] ?? config.stellar.networkPassphrase; const tx = TransactionBuilder.fromXDR(signedTxXdr, networkPassphrase); - const server = new SorobanRpc.Server(config.stellar.rpcUrl, { - allowHttp: config.stellar.rpcUrl.startsWith('http://'), - }); - - const sendResponse = await server.sendTransaction(tx).catch((err: Error) => { + const sendResponse = await rpcCall((server) => server.sendTransaction(tx), { + signal: opts.signal, + operationName: 'sendTransaction', + }).catch((err: Error) => { logger.error({ err }, 'Withdrawal transaction submission failed'); throw new BadRequestError('Failed to submit withdrawal transaction'); }); diff --git a/backend/src/modules/x/x.circuit-breaker.ts b/backend/src/modules/x/x.circuit-breaker.ts index 0e3a95d7..f303a84f 100644 --- a/backend/src/modules/x/x.circuit-breaker.ts +++ b/backend/src/modules/x/x.circuit-breaker.ts @@ -1,67 +1,11 @@ -import { ServiceUnavailableError } from "../../common/errors/AppError.js"; -import { logger } from "../../common/utils/logger.js"; - -export type CircuitBreakerState = "CLOSED" | "OPEN" | "HALF_OPEN"; - -export class CircuitBreaker { - private state: CircuitBreakerState = "CLOSED"; - private failureCount = 0; - private lastFailureTime = 0; - - constructor( - private readonly failureThreshold = 5, - private readonly resetTimeoutMs = 30_000, - ) {} - - getState(): CircuitBreakerState { - return this.state; - } - - getFailureCount(): number { - return this.failureCount; - } - - async call(fn: () => Promise): Promise { - if (this.state === "OPEN") { - const elapsed = Date.now() - this.lastFailureTime; - if (elapsed >= this.resetTimeoutMs) { - logger.info("Circuit breaker transitioning to HALF_OPEN"); - this.state = "HALF_OPEN"; - } else { - throw new ServiceUnavailableError( - "X API circuit breaker is open - too many failures", - ); - } - } - - try { - const result = await fn(); - if (this.state === "HALF_OPEN") { - logger.info("Circuit breaker reset to CLOSED after successful call"); - this.reset(); - } - this.failureCount = 0; - this.lastFailureTime = 0; - return result; - } catch (error) { - this.failureCount++; - this.lastFailureTime = Date.now(); - if (this.failureCount >= this.failureThreshold) { - logger.warn( - { failureCount: this.failureCount }, - "Circuit breaker OPEN - too many failures", - ); - this.state = "OPEN"; - } - throw error; - } - } - - reset(): void { - this.state = "CLOSED"; - this.failureCount = 0; - this.lastFailureTime = 0; - } -} - -export const xCircuitBreaker = new CircuitBreaker(); +/** + * Re-export shared circuit breaker — extracted without changing X's behaviour (issue #091). + * The shared utility lives in src/common/utils/circuitBreaker.ts so RPC/Horizon can reuse it. + * This file remains the import surface for X so x.circuit-breaker.test.ts passes unmodified. + */ +export { + CircuitBreaker, + xCircuitBreaker, + type CircuitBreakerState, + getCircuitBreakerMetrics, +} from "../../common/utils/circuitBreaker.js"; diff --git a/backend/src/modules/x/x.client.ts b/backend/src/modules/x/x.client.ts index b87a159b..a6d471eb 100644 --- a/backend/src/modules/x/x.client.ts +++ b/backend/src/modules/x/x.client.ts @@ -2,6 +2,7 @@ import { config } from '../../config/index.js'; import { BadGatewayError } from '../../common/errors/AppError.js'; import { logger } from '../../common/utils/logger.js'; import { xCircuitBreaker, type CircuitBreaker } from './x.circuit-breaker.js'; +import { fetchWithTimeout } from '../../common/utils/fetchWithTimeout.js'; export interface XApiUser { id: string; @@ -32,8 +33,8 @@ export interface XRateLimitInfo { limit: number | null; } -const BASE_URL = config.twitter.baseUrl; -const BEARER_TOKEN = config.twitter.bearerToken; +const BASE_URL = (config as unknown as { twitter?: { baseUrl: string } })?.twitter?.baseUrl ?? 'https://api.twitter.com/2'; +const BEARER_TOKEN = (config as unknown as { twitter?: { bearerToken?: string } })?.twitter?.bearerToken; export class XApiClient { private readonly baseUrl: string; @@ -111,7 +112,7 @@ export class XApiClient { private async executeRequest( path: string, - options: RequestInit | undefined, + options: RequestInit & { parentSignal?: AbortSignal } | undefined, attempt: number, ): Promise { const url = `${this.baseUrl}${path}`; @@ -124,10 +125,30 @@ export class XApiClient { logger.debug({ url, attempt: attempt + 1 }, 'X API request'); + // Timeouts are explicit and configurable (issue #090); parentSignal carries client-disconnect cancellation + const timeoutMs = (config as unknown as { timeouts?: { xApiMs: number } })?.timeouts?.xApiMs ?? 10_000; + const parentSignal = (options as unknown as { parentSignal?: AbortSignal })?.parentSignal; + const explicitSignal = (options as unknown as { signal?: AbortSignal })?.signal; + let response: Response; try { - response = await fetch(url, { ...options, headers }); + response = await fetchWithTimeout(url, { + ...options, + headers, + timeoutMs, + parentSignal: parentSignal ?? explicitSignal ?? undefined, + // Remove explicit signal to avoid duplication — fetchWithTimeout merges them + signal: undefined, + }); } catch (err) { + if (err instanceof DOMException && err.name === 'TimeoutError') { + logger.warn({ url, timeoutMs }, 'X API request timed out'); + throw new BadGatewayError(`X API request timed out after ${timeoutMs}ms`); + } + if (err instanceof DOMException && err.name === 'AbortError') { + logger.debug({ url }, 'X API request aborted (client disconnect)'); + throw new BadGatewayError('X API request cancelled'); + } logger.error({ err, url }, 'X API network error'); throw new BadGatewayError( `X API request failed: ${(err as Error).message}`, @@ -175,18 +196,18 @@ export class XApiClient { return body; } - async getUserByHandle(handle: string): Promise { + async getUserByHandle(handle: string, opts: { signal?: AbortSignal } = {}): Promise { const path = `/users/by/username/${encodeURIComponent(handle)}?user.fields=public_metrics`; return this.request(path, { - signal: AbortSignal.timeout(10_000), - }); + parentSignal: opts.signal, + } as RequestInit & { parentSignal?: AbortSignal }); } - async getUserById(id: string): Promise { + async getUserById(id: string, opts: { signal?: AbortSignal } = {}): Promise { const path = `/users/${encodeURIComponent(id)}?user.fields=public_metrics`; return this.request(path, { - signal: AbortSignal.timeout(10_000), - }); + parentSignal: opts.signal, + } as RequestInit & { parentSignal?: AbortSignal }); } } diff --git a/backend/src/modules/x/x.controller.ts b/backend/src/modules/x/x.controller.ts index ddd1e557..3cd57910 100644 --- a/backend/src/modules/x/x.controller.ts +++ b/backend/src/modules/x/x.controller.ts @@ -30,6 +30,7 @@ export async function getXMetricsController( const metrics = await fetchXMetrics(input.handle, { useFallback: input.useFallback, maxCacheAge: input.maxCacheAge, + signal: req.signal, }); res.json({ diff --git a/backend/src/modules/x/x.service.ts b/backend/src/modules/x/x.service.ts index 913c7bd2..257c8a55 100644 --- a/backend/src/modules/x/x.service.ts +++ b/backend/src/modules/x/x.service.ts @@ -1,5 +1,6 @@ import { prisma } from "../../db/prisma.js"; import { env } from "../../config/env.js"; +import { config } from "../../config/index.js"; import { logger } from "../../common/utils/logger.js"; import { BadRequestError, @@ -7,6 +8,7 @@ import { ServiceUnavailableError, } from "../../common/errors/AppError.js"; import { xCircuitBreaker } from "./x.circuit-breaker.js"; +import { fetchWithTimeout } from "../../common/utils/fetchWithTimeout.js"; import type { XAccountMetrics, XApiUserResponse, @@ -57,10 +59,11 @@ class XApiClient { /** * Fetches user data from X API by handle. * @param handle - X handle (without @ symbol) + * @param opts - optional AbortSignal for cancellation (issue #090) * @returns X API user response * @throws {ServiceUnavailableError} if API is unavailable or token is missing */ - async fetchUserByHandle(handle: string): Promise { + async fetchUserByHandle(handle: string, opts: { signal?: AbortSignal } = {}): Promise { if (!this.bearerToken) { throw new ServiceUnavailableError("X API bearer token not configured"); } @@ -70,7 +73,7 @@ class XApiClient { for (let attempt = 0; attempt <= this.maxRetries; attempt++) { try { - return await this.executeFetch(handle, attempt); + return await this.executeFetch(handle, attempt, opts.signal); } catch (error) { lastError = error; if ( @@ -93,18 +96,30 @@ class XApiClient { private async executeFetch( handle: string, attempt: number, + parentSignal?: AbortSignal, ): Promise { const url = `${this.baseUrl}/users/by/username/${handle}?user.fields=public_metrics`; + const timeoutMs = (config as unknown as { timeouts?: { xApiMs: number } })?.timeouts?.xApiMs ?? 10_000; let response: Response; try { - response = await fetch(url, { + response = await fetchWithTimeout(url, { headers: { Authorization: `Bearer ${this.bearerToken!}`, "Content-Type": "application/json", }, + timeoutMs, + parentSignal, }); } catch (error) { + if (error instanceof DOMException && error.name === "TimeoutError") { + logger.warn({ handle, timeoutMs }, "X API timeout"); + throw new ServiceUnavailableError("X API request timed out"); + } + if (error instanceof DOMException && error.name === "AbortError") { + logger.debug({ handle }, "X API request aborted (client disconnect)"); + throw new ServiceUnavailableError("X API request cancelled"); + } logger.error({ error, handle }, "Failed to fetch X user data"); throw new ServiceUnavailableError("Failed to connect to X API"); } @@ -169,13 +184,13 @@ function normalizeXMetrics( */ export async function fetchXMetrics( handle: string, - options: FetchXMetricsOptions = {}, + options: FetchXMetricsOptions & { signal?: AbortSignal } = {}, ): Promise { - const { useFallback = true, maxCacheAge = 24 * 60 * 60 * 1000 } = options; + const { useFallback = true, maxCacheAge = 24 * 60 * 60 * 1000, signal } = options; try { - // Try to fetch fresh data from X API - const apiResponse = await xApiClient.fetchUserByHandle(handle); + // Try to fetch fresh data from X API (signal propagates client disconnect, timeout via config) + const apiResponse = await xApiClient.fetchUserByHandle(handle, { signal }); const metrics = normalizeXMetrics(handle, apiResponse); // Cache the result in database