From 762059226c7d4d48168e6a937940ef3b8492141a Mon Sep 17 00:00:00 2001 From: David Meister Date: Sat, 13 Jun 2026 10:44:06 +0000 Subject: [PATCH] Retry SDK indexing on transient timeout before failing Fixes #2327. Timed-out indexing promises (deposit / withdraw / order txs) surfaced as a hard failure on the very first timeout, even though the timeout is transient (the subgraph just hasn't indexed the tx yet). createSdkIndexingFn now retries the SDK call up to DEFAULT_MAX_RETRIES (3) total attempts on a timeout result, with the existing retry() backoff, before marking the transaction failed. Only timeouts are retried: a successful result, a non-timeout hard error, and a rejection thrown by the SDK call itself are all surfaced after a single attempt (unchanged behavior). Adds a retry() util to ui-components mirroring the webapp one, since TransactionManager lives in ui-components and cannot import from webapp (webapp depends on ui-components, not the reverse). Co-Authored-By: Claude Opus 4.8 --- .../src/__tests__/TransactionManager.test.ts | 284 +++++++++++++----- .../transactions/TransactionManager.ts | 65 +++- packages/ui-components/src/lib/utils/retry.ts | 16 + 3 files changed, 292 insertions(+), 73 deletions(-) create mode 100644 packages/ui-components/src/lib/utils/retry.ts diff --git a/packages/ui-components/src/__tests__/TransactionManager.test.ts b/packages/ui-components/src/__tests__/TransactionManager.test.ts index ff293a494f..689c342b0d 100644 --- a/packages/ui-components/src/__tests__/TransactionManager.test.ts +++ b/packages/ui-components/src/__tests__/TransactionManager.test.ts @@ -265,41 +265,52 @@ describe("TransactionManager", () => { }); it("should handle SDK timeout error in awaitIndexingFn", async () => { - const mockTransaction = { execute: vi.fn() }; - vi.mocked(TransactionStore).mockImplementation( - () => mockTransaction as unknown as TransactionStore, - ); - - await manager.createRemoveOrderTransaction(removeOrderMockArgs); - - const callArgs = vi.mocked(TransactionStore).mock.calls[0][0]; - - const mockContext: IndexingContext = { - updateState: vi.fn(), - onSuccess: vi.fn(), - onError: vi.fn(), - links: [], - }; - - // Mock a timeout error from the SDK - vi.mocked( - mockRaindexClient.getRemoveOrdersForTransaction, - ).mockResolvedValueOnce({ - error: { - readableMsg: - "Timeout waiting for the subgraph to index transaction 0x123 after 10 attempts.", - }, - } as unknown as WasmEncodedResult); - - await callArgs.awaitIndexingFn!(mockContext); - - // Verify error handling - expect(mockContext.updateState).toHaveBeenCalledWith({ - status: TransactionStatusMessage.ERROR, - errorDetails: TransactionStoreErrorMessage.SUBGRAPH_TIMEOUT_ERROR, - }); - expect(mockContext.onError).toHaveBeenCalled(); - expect(mockContext.onSuccess).not.toHaveBeenCalled(); + vi.useFakeTimers(); + try { + const mockTransaction = { execute: vi.fn() }; + vi.mocked(TransactionStore).mockImplementation( + () => mockTransaction as unknown as TransactionStore, + ); + + await manager.createRemoveOrderTransaction(removeOrderMockArgs); + + const callArgs = vi.mocked(TransactionStore).mock.calls[0][0]; + + const mockContext: IndexingContext = { + updateState: vi.fn(), + onSuccess: vi.fn(), + onError: vi.fn(), + links: [], + }; + + // Mock a persistent timeout error from the SDK: it keeps timing out + // across every retry, so indexing ultimately fails with a timeout. + vi.mocked( + mockRaindexClient.getRemoveOrdersForTransaction, + ).mockResolvedValue({ + error: { + readableMsg: + "Timeout waiting for the subgraph to index transaction 0x123 after 10 attempts.", + }, + } as unknown as WasmEncodedResult); + + const pending = callArgs.awaitIndexingFn!(mockContext); + await vi.runAllTimersAsync(); + await pending; + + // Retries until exhausted, then surfaces the timeout error. + expect( + mockRaindexClient.getRemoveOrdersForTransaction, + ).toHaveBeenCalledTimes(3); + expect(mockContext.updateState).toHaveBeenCalledWith({ + status: TransactionStatusMessage.ERROR, + errorDetails: TransactionStoreErrorMessage.SUBGRAPH_TIMEOUT_ERROR, + }); + expect(mockContext.onError).toHaveBeenCalled(); + expect(mockContext.onSuccess).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } }); }); @@ -876,40 +887,49 @@ describe("TransactionManager", () => { }); it("should handle SDK timeout error in awaitIndexingFn", async () => { - const mockTransaction = { execute: vi.fn() }; - vi.mocked(TransactionStore).mockImplementation( - () => mockTransaction as unknown as TransactionStore, - ); - - await manager.createDepositTransaction(mockArgs); - - const callArgs = vi.mocked(TransactionStore).mock.calls[0][0]; - - const mockContext: IndexingContext = { - updateState: vi.fn(), - onSuccess: vi.fn(), - onError: vi.fn(), - links: [], - }; - - // Mock a timeout error from the SDK - vi.mocked(mockRaindexClient.getTransaction).mockResolvedValueOnce({ - error: { - readableMsg: - "Timeout waiting for transaction 0x123 to be indexed after 10 attempts.", - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); - - await callArgs.awaitIndexingFn!(mockContext); - - // Verify error handling - expect(mockContext.updateState).toHaveBeenCalledWith({ - status: TransactionStatusMessage.ERROR, - errorDetails: TransactionStoreErrorMessage.SUBGRAPH_TIMEOUT_ERROR, - }); - expect(mockContext.onError).toHaveBeenCalled(); - expect(mockContext.onSuccess).not.toHaveBeenCalled(); + vi.useFakeTimers(); + try { + const mockTransaction = { execute: vi.fn() }; + vi.mocked(TransactionStore).mockImplementation( + () => mockTransaction as unknown as TransactionStore, + ); + + await manager.createDepositTransaction(mockArgs); + + const callArgs = vi.mocked(TransactionStore).mock.calls[0][0]; + + const mockContext: IndexingContext = { + updateState: vi.fn(), + onSuccess: vi.fn(), + onError: vi.fn(), + links: [], + }; + + // Mock a persistent timeout error from the SDK: it keeps timing out + // across every retry, so indexing ultimately fails with a timeout. + vi.mocked(mockRaindexClient.getTransaction).mockResolvedValue({ + error: { + readableMsg: + "Timeout waiting for transaction 0x123 to be indexed after 10 attempts.", + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + const pending = callArgs.awaitIndexingFn!(mockContext); + await vi.runAllTimersAsync(); + await pending; + + // Retries until exhausted, then surfaces the timeout error. + expect(mockRaindexClient.getTransaction).toHaveBeenCalledTimes(3); + expect(mockContext.updateState).toHaveBeenCalledWith({ + status: TransactionStatusMessage.ERROR, + errorDetails: TransactionStoreErrorMessage.SUBGRAPH_TIMEOUT_ERROR, + }); + expect(mockContext.onError).toHaveBeenCalled(); + expect(mockContext.onSuccess).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } }); }); @@ -1276,6 +1296,21 @@ describe("createSdkIndexingFn", () => { expect(linksUpdateCalls).toHaveLength(0); }); + // These persistent-timeout cases exercise the retry backoff, so they drive + // the indexing fn under fake timers to avoid real-time waits. + const runIndexingWithTimers = async ( + indexingFn: (ctx: IndexingContext) => Promise, + ) => { + vi.useFakeTimers(); + try { + const pending = indexingFn(mockContext); + await vi.runAllTimersAsync(); + await pending; + } finally { + vi.useRealTimers(); + } + }; + it('should set SUBGRAPH_TIMEOUT_ERROR and call onError when error contains "timeout"', async () => { const mockCall = vi.fn().mockResolvedValue({ error: { readableMsg: "Request timeout exceeded" }, @@ -1285,7 +1320,7 @@ describe("createSdkIndexingFn", () => { isSuccess: () => true, }); - await indexingFn(mockContext); + await runIndexingWithTimers(indexingFn); expect(mockUpdateState).toHaveBeenCalledWith({ status: TransactionStatusMessage.ERROR, @@ -1304,7 +1339,7 @@ describe("createSdkIndexingFn", () => { isSuccess: () => true, }); - await indexingFn(mockContext); + await runIndexingWithTimers(indexingFn); expect(mockUpdateState).toHaveBeenCalledWith({ status: TransactionStatusMessage.ERROR, @@ -1324,7 +1359,7 @@ describe("createSdkIndexingFn", () => { isSuccess: () => true, }); - await indexingFn(mockContext); + await runIndexingWithTimers(indexingFn); expect(mockUpdateState).toHaveBeenCalledWith({ status: TransactionStatusMessage.ERROR, @@ -1444,4 +1479,109 @@ describe("createSdkIndexingFn", () => { }); expect(mockOnSuccess).toHaveBeenCalled(); }); + + describe("timeout retry behavior", () => { + const timeoutResult = { + error: { readableMsg: "Request timeout exceeded" }, + } as WasmEncodedResult; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // Drives an async fn that interleaves awaited SDK calls with the + // fake-timer backoff sleeps from retry(), flushing both the + // microtask queue and the pending timers until the fn settles. + const runAllTimers = async (promise: Promise) => { + await vi.runAllTimersAsync(); + return promise; + }; + + it("should retry the SDK call up to DEFAULT_MAX_RETRIES times on repeated timeout before failing", async () => { + const mockCall = vi.fn().mockResolvedValue(timeoutResult); + const indexingFn = createSdkIndexingFn({ + call: mockCall, + isSuccess: () => true, + }); + + await runAllTimers(indexingFn(mockContext)); + + // Buggy (no-retry) code calls exactly once; retry calls 3 times total. + expect(mockCall).toHaveBeenCalledTimes(3); + expect(mockUpdateState).toHaveBeenCalledWith({ + status: TransactionStatusMessage.ERROR, + errorDetails: TransactionStoreErrorMessage.SUBGRAPH_TIMEOUT_ERROR, + }); + expect(mockOnError).toHaveBeenCalledTimes(1); + expect(mockOnSuccess).not.toHaveBeenCalled(); + }); + + it("should recover and succeed when a retry after a timeout returns valid data", async () => { + const mockCall = vi + .fn() + .mockResolvedValueOnce(timeoutResult) + .mockResolvedValueOnce({ value: ["order1"] }); + const indexingFn = createSdkIndexingFn({ + call: mockCall, + isSuccess: (value: string[]) => value.length > 0, + }); + + await runAllTimers(indexingFn(mockContext)); + + // Buggy code marks ERROR on the first timeout; retry recovers on attempt 2. + expect(mockCall).toHaveBeenCalledTimes(2); + expect(mockUpdateState).toHaveBeenCalledWith({ + status: TransactionStatusMessage.SUCCESS, + }); + expect(mockOnSuccess).toHaveBeenCalledTimes(1); + expect(mockOnError).not.toHaveBeenCalled(); + expect(mockUpdateState).not.toHaveBeenCalledWith( + expect.objectContaining({ + errorDetails: TransactionStoreErrorMessage.SUBGRAPH_TIMEOUT_ERROR, + }), + ); + }); + + it("should NOT retry on a non-timeout hard error", async () => { + const mockCall = vi.fn().mockResolvedValue({ + error: { readableMsg: "Network connection failed" }, + } as WasmEncodedResult); + const indexingFn = createSdkIndexingFn({ + call: mockCall, + isSuccess: () => true, + }); + + await runAllTimers(indexingFn(mockContext)); + + // Hard errors are not transient: fail fast, exactly one call. + expect(mockCall).toHaveBeenCalledTimes(1); + expect(mockUpdateState).toHaveBeenCalledWith({ + status: TransactionStatusMessage.ERROR, + errorDetails: TransactionStoreErrorMessage.SUBGRAPH_FAILED, + }); + expect(mockOnError).toHaveBeenCalledTimes(1); + }); + + it("should NOT retry when the SDK call throws synchronously-rejecting (non-timeout) exception", async () => { + const mockCall = vi.fn().mockRejectedValue(new Error("boom")); + const indexingFn = createSdkIndexingFn({ + call: mockCall, + isSuccess: () => true, + }); + + await runAllTimers(indexingFn(mockContext)); + + // A thrown non-timeout error is not a transient timeout: one call only. + expect(mockCall).toHaveBeenCalledTimes(1); + expect(mockUpdateState).toHaveBeenCalledWith({ + status: TransactionStatusMessage.ERROR, + errorDetails: TransactionStoreErrorMessage.SUBGRAPH_FAILED, + }); + expect(mockOnError).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/ui-components/src/lib/providers/transactions/TransactionManager.ts b/packages/ui-components/src/lib/providers/transactions/TransactionManager.ts index 2610ce1f65..19c83a0d8e 100644 --- a/packages/ui-components/src/lib/providers/transactions/TransactionManager.ts +++ b/packages/ui-components/src/lib/providers/transactions/TransactionManager.ts @@ -22,6 +22,7 @@ import { Float, type WasmEncodedResult, } from "@rainlanguage/raindex"; +import { retry, DEFAULT_MAX_RETRIES } from "$lib/utils/retry"; /** * Function type for adding toast notifications to the UI. @@ -33,11 +34,73 @@ import { */ export type AddToastFunction = (toast: Omit) => void; +/** + * Determines whether an SDK result represents a transaction-indexing timeout. + * Timeouts are transient (the subgraph just hasn't indexed the tx yet), so they + * are retried rather than surfaced as a hard failure on the first occurrence. + */ +function isTimeoutResult(result: WasmEncodedResult): boolean { + return (result.error?.readableMsg?.toLowerCase() ?? "").includes("timeout"); +} + +/** + * Sentinel thrown to make {@link retry} re-invoke the SDK call on a transient + * timeout result. Only timeouts trigger a retry; everything else short-circuits. + */ +class TimeoutRetrySignal extends Error {} + +/** + * Calls the SDK and, when the result is a transient indexing timeout, retries + * up to {@link DEFAULT_MAX_RETRIES} total attempts (with the backoff from + * {@link retry}) before giving up and returning the last timeout result. + * + * Only timeouts are retried. A successful result, a non-timeout hard error + * result, and a rejection thrown by `call` itself are all surfaced after a + * single attempt (the rejection is re-thrown to the caller's existing catch). + */ +async function callWithTimeoutRetry( + call: () => Promise>, +): Promise> { + // `retry` re-invokes `fn` on ANY throw, so a real rejection from `call` is + // captured here and replayed after the loop rather than thrown inside it — + // that keeps non-timeout failures from being retried. Only a deliberate + // TimeoutRetrySignal drives a retry. + let lastTimeoutResult: WasmEncodedResult | undefined; + let callRejection: { error: unknown } | undefined; + const result = await retry(async () => { + let r: WasmEncodedResult; + try { + r = await call(); + } catch (error) { + callRejection = { error }; + return undefined; + } + if (isTimeoutResult(r)) { + lastTimeoutResult = r; + throw new TimeoutRetrySignal(); + } + return r; + }, DEFAULT_MAX_RETRIES).catch((e) => { + // Retries exhausted on repeated timeouts: surface the last timeout result so + // the caller renders SUBGRAPH_TIMEOUT_ERROR. Anything else is unexpected. + if (e instanceof TimeoutRetrySignal && lastTimeoutResult) { + return lastTimeoutResult; + } + throw e; + }); + + if (callRejection) throw callRejection.error; + return result as WasmEncodedResult; +} + /** * Creates an indexing function that wraps SDK-based polling logic. * The SDK handles local-DB-first polling followed by subgraph fallback internally, * so we only need to call it once. * + * Transient transaction-indexing timeouts are retried up to + * {@link DEFAULT_MAX_RETRIES} times before the transaction is marked as failed. + * * @param options Configuration for SDK-based indexing * @param options.call Function that calls the SDK method (e.g. getAddOrdersForTransaction) * @param options.isSuccess Function to determine if the result indicates success @@ -53,7 +116,7 @@ export function createSdkIndexingFn(options: { ctx.updateState({ status: TransactionStatusMessage.PENDING_SUBGRAPH }); try { - const result = await options.call(); + const result = await callWithTimeoutRetry(options.call); if (result.error) { const errorMsg = result.error.readableMsg?.toLowerCase() ?? ""; diff --git a/packages/ui-components/src/lib/utils/retry.ts b/packages/ui-components/src/lib/utils/retry.ts new file mode 100644 index 0000000000..5c0e2fb4a7 --- /dev/null +++ b/packages/ui-components/src/lib/utils/retry.ts @@ -0,0 +1,16 @@ +export const DEFAULT_MAX_RETRIES = 3; + +export async function retry( + fn: () => Promise, + retries = DEFAULT_MAX_RETRIES, +): Promise { + for (let i = 0; i < retries; i++) { + try { + return await fn(); + } catch (e) { + if (i === retries - 1) throw e; + await new Promise((r) => setTimeout(r, 1000 * (i + 1))); + } + } + throw new Error("unreachable"); +}