From e33d3f787b9161fb52ecc5edb88b67893820709d Mon Sep 17 00:00:00 2001 From: odarome132 Date: Thu, 27 Aug 2026 10:30:06 +0000 Subject: [PATCH] fix: bound Horizon payment-lookup latency so large-dataset queries fail fast Issue #2: a query over very large datasets times out occasionally. The payment-history lookup through the Stellar Horizon SDK had no request deadline, so an account with a large payment history (or a slow Horizon) could hang until the caller timed out. Adds a HORIZON_REQUEST_TIMEOUT_MS cap and a withTimeout() helper, applied to fetchRecentPayments so it rejects with a clear error instead of stalling into a timeout. --- backend/src/stellar/horizon.ts | 36 +++++++++++++++++++++++----- backend/src/tests/horizon.test.ts | 40 +++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 backend/src/tests/horizon.test.ts diff --git a/backend/src/stellar/horizon.ts b/backend/src/stellar/horizon.ts index dc5c136..2cd2993 100644 --- a/backend/src/stellar/horizon.ts +++ b/backend/src/stellar/horizon.ts @@ -15,6 +15,27 @@ import { Horizon, Networks, Asset } from "@stellar/stellar-sdk"; export const HORIZON_URL = process.env.HORIZON_URL ?? "https://horizon-testnet.stellar.org"; +// Hard cap on how long a single Horizon read (e.g. fetching a large account's +// payment history) may take. Horizon can be slow for accounts with very large +// payment datasets; without a bound this can hang a request until the caller +// times out (issue #2). Failing fast with a clear error is better than dropping. +export const HORIZON_REQUEST_TIMEOUT_MS = 10_000; + +/** + * Rejects `promise` if it does not settle within `ms`, with a clear timeout + * error. Otherwise resolves/rejects with the original result. + */ +export function withTimeout(promise: Promise, ms: number): Promise { + let timer: ReturnType; + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`Horizon request timed out after ${ms}ms`)), + ms + ); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + export const NETWORK_PASSPHRASE = process.env.STELLAR_NETWORK === "mainnet" ? Networks.PUBLIC @@ -124,12 +145,15 @@ export async function fetchRecentPayments( limit = 20 ): Promise { try { - const records = await getHorizon() - .payments() - .forAccount(accountId) - .order("desc") - .limit(limit) - .call(); + const records = await withTimeout( + getHorizon() + .payments() + .forAccount(accountId) + .order("desc") + .limit(limit) + .call(), + HORIZON_REQUEST_TIMEOUT_MS + ); return records.records .filter((r: any) => r.type === "payment" && r.to === accountId) diff --git a/backend/src/tests/horizon.test.ts b/backend/src/tests/horizon.test.ts new file mode 100644 index 0000000..39a00de --- /dev/null +++ b/backend/src/tests/horizon.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, vi } from "vitest"; +import { withTimeout, HORIZON_REQUEST_TIMEOUT_MS } from "../stellar/horizon"; + +const never = new Promise(() => {}); + +describe("Horizon withTimeout helper", () => { + it("resolves with the inner promise's value when it settles within the timeout", async () => { + const value = { id: "p1", amount: "10.0000000" }; + const inner = Promise.resolve(value); + + await expect(withTimeout(inner, 100)).resolves.toBe(value); + }); + + it("rejects with a clear timeout error when the inner promise never settles (large/slow dataset)", async () => { + await expect(withTimeout(never, 10)).rejects.toThrow( + "Horizon request timed out after 10ms" + ); + }); + + it("still rejects with the inner promise's own error when it fails before the deadline", async () => { + const inner = Promise.reject(new Error("Horizon down")); + + await expect(withTimeout(inner, 100)).rejects.toThrow("Horizon down"); + }); + + it("clears its internal timer once the inner promise settles (no leaked handle)", async () => { + // spyOn global setTimeout/clearTimeout to confirm cleanup after resolution + const clearSpy = vi.spyOn(globalThis, "clearTimeout"); + + await withTimeout(Promise.resolve(1), 100); + + expect(clearSpy).toHaveBeenCalled(); + clearSpy.mockRestore(); + }); + + it("exposes a bounded, positive Horizon timeout", () => { + expect(HORIZON_REQUEST_TIMEOUT_MS).toBeGreaterThan(0); + expect(HORIZON_REQUEST_TIMEOUT_MS).toBeLessThanOrEqual(60_000); + }); +}); \ No newline at end of file