Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 30 additions & 6 deletions backend/src/stellar/horizon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(promise: Promise<T>, ms: number): Promise<T> {
let timer: ReturnType<typeof setTimeout>;
const timeout = new Promise<never>((_, 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
Expand Down Expand Up @@ -124,12 +145,15 @@ export async function fetchRecentPayments(
limit = 20
): Promise<StellarPayment[]> {
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)
Expand Down
40 changes: 40 additions & 0 deletions backend/src/tests/horizon.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, it, expect, vi } from "vitest";
import { withTimeout, HORIZON_REQUEST_TIMEOUT_MS } from "../stellar/horizon";

const never = new Promise<never>(() => {});

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);
});
});
Loading