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
37 changes: 36 additions & 1 deletion packages/shared/src/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { withRetry, sanitizeTxError } from "./utils";
import { withRetry, withRaceTimeout, sanitizeTxError } from "./utils";

describe("sanitizeTxError", () => {
it("returns the fallback for non-Error values", () => {
Expand Down Expand Up @@ -103,3 +103,38 @@ describe("withRetry", () => {
expect(fn).toHaveBeenCalledTimes(2);
});
});

describe("withRaceTimeout", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());

it("resolves with fn's value when fn completes before the timeout", async () => {
const fn = vi.fn().mockResolvedValue("result");
const result = await withRaceTimeout(fn, 1_000, "test");
expect(result).toBe("result");
});

it("rejects with a timeout error when fn does not complete in time", async () => {
const fn = vi.fn().mockImplementation(() => new Promise(() => {}));
const promise = withRaceTimeout(fn, 500, "test op");
const assertion = expect(promise).rejects.toThrow("test op timed out after 500ms");
await vi.runAllTimersAsync();
await assertion;
});

it("clears the timeout handle when fn resolves so no lingering timer fires", async () => {
const clearSpy = vi.spyOn(globalThis, "clearTimeout");
const fn = vi.fn().mockResolvedValue("ok");
await withRaceTimeout(fn, 1_000, "test");
expect(clearSpy).toHaveBeenCalled();
clearSpy.mockRestore();
});

it("clears the timeout handle when fn rejects", async () => {
const clearSpy = vi.spyOn(globalThis, "clearTimeout");
const fn = vi.fn().mockRejectedValue(new Error("boom"));
await expect(withRaceTimeout(fn, 1_000, "test")).rejects.toThrow("boom");
expect(clearSpy).toHaveBeenCalled();
clearSpy.mockRestore();
});
});
14 changes: 14 additions & 0 deletions packages/shared/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@ export function isValidStellarAddress(key: string): boolean {
return /^G[A-Z2-7]{55}$/.test(key);
}

/**
* Races `fn` against a timeout. Clears the timer if `fn` resolves first so no
* handle lingers in the event loop after a successful call.
*/
export function withRaceTimeout<T>(fn: () => Promise<T>, ms: number, label: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const handle = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
fn().then(
(val) => { clearTimeout(handle); resolve(val); },
(err) => { clearTimeout(handle); reject(err); }
);
});
}

/**
* Retries `fn` up to `maxAttempts` times with exponential backoff starting at
* `baseDelayMs`. Throws the last error when all attempts are exhausted.
Expand Down
16 changes: 5 additions & 11 deletions packages/stellar-sdk-helpers/src/blend.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { xdr } from "@stellar/stellar-sdk";
import { PoolContractV2, PoolV2, RequestType } from "@blend-capital/blend-sdk";
import { withRetry } from "@meridian/shared";
import { withRetry, withRaceTimeout } from "@meridian/shared";
import { prepareSorobanTx } from "./tx";
import type { StellarNetwork } from "./types";
import type { PositionInfo } from "./positions";
Expand All @@ -11,14 +11,8 @@ const BLEND_RPC_TIMEOUT_MS = 10_000;
// manual timeout rejection. The underlying fetch will still complete, but the
// caller gets a fast failure it can retry rather than waiting for Vercel's
// function-level deadline.
function withTimeout<T>(fn: () => Promise<T>, ms = BLEND_RPC_TIMEOUT_MS): Promise<T> {
return Promise.race([
fn(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`Blend RPC timed out after ${ms}ms`)), ms)
),
]);
}
const withBlendTimeout = <T>(fn: () => Promise<T>, ms = BLEND_RPC_TIMEOUT_MS) =>
withRaceTimeout(fn, ms, "Blend RPC");

export interface BlendPoolConfig {
// Blend pool contract (C...) the request is submitted to.
Expand Down Expand Up @@ -113,9 +107,9 @@ export async function fetchBlendPositions(
reserves: BlendReserveRef[]
): Promise<PositionInfo[]> {
const pool = await withRetry(() =>
withTimeout(() => PoolV2.load({ rpc: network.rpcUrl, passphrase: network.passphrase }, poolId))
withBlendTimeout(() => PoolV2.load({ rpc: network.rpcUrl, passphrase: network.passphrase }, poolId))
);
const user = await withRetry(() => withTimeout(() => pool.loadUser(publicKey)));
const user = await withRetry(() => withBlendTimeout(() => pool.loadUser(publicKey)));

const positions: PositionInfo[] = [];
for (const { assetId, vaultId } of reserves) {
Expand Down
16 changes: 8 additions & 8 deletions packages/stellar-sdk-helpers/src/tx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,14 @@ export class SorobanTimeoutError extends Error {
}
}

function withSorobanTimeout<T>(fn: () => Promise<T>, ms = SOROBAN_RPC_TIMEOUT_MS): Promise<T> {
return Promise.race([
fn(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new SorobanTimeoutError(ms)), ms)
),
]);
}
const withSorobanTimeout = <T>(fn: () => Promise<T>, ms = SOROBAN_RPC_TIMEOUT_MS): Promise<T> =>
new Promise<T>((resolve, reject) => {
const handle = setTimeout(() => reject(new SorobanTimeoutError(ms)), ms);
fn().then(
(val) => { clearTimeout(handle); resolve(val); },
(err) => { clearTimeout(handle); reject(err); }
);
});

const USDC_ISSUER: Record<string, string> = {
testnet: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
Expand Down