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
5 changes: 5 additions & 0 deletions .changeset/giant-frogs-collect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"thirdweb": patch
---

Automatic retries on watchContractEvents
22 changes: 15 additions & 7 deletions packages/thirdweb/src/event/actions/watch-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
} from "./get-events.js";

import { watchBlockNumber } from "../../rpc/watchBlockNumber.js";
import { retry } from "../../utils/retry.js";
import type { Prettify } from "../../utils/type-utils.js";
import type { PreparedEvent } from "../prepare-event.js";
import type { ParseEventLogsResult } from "./parse-logs.js";
Expand Down Expand Up @@ -68,13 +69,20 @@ export function watchContractEvents<
* @internal
*/
onNewBlockNumber: async (blockNumber) => {
const logs = await getContractEvents({
...options,
// fromBlock is inclusive
fromBlock: blockNumber,
// toBlock is inclusive
toBlock: blockNumber,
});
const logs = await retry(
async () =>
getContractEvents({
...options,
// fromBlock is inclusive
fromBlock: blockNumber,
// toBlock is inclusive
toBlock: blockNumber,
}),
{
retries: 3,
delay: 500,
},
);
// if there were any logs associated with our event(s)
if (logs.length) {
options.onEvents(logs);
Expand Down
36 changes: 31 additions & 5 deletions packages/thirdweb/src/utils/any-evm/is-eip155-enforced.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,38 @@
import { describe, expect, it } from "vitest";
import { ANVIL_CHAIN } from "../../../test/src/chains.js";
import { beforeEach } from "node:test";
import { afterAll, describe, expect, it, vi } from "vitest";
import {
ANVIL_CHAIN,
FORKED_ETHEREUM_CHAIN,
} from "../../../test/src/chains.js";
import { TEST_CLIENT } from "../../../test/src/test-clients.js";
import { base } from "../../chains/chain-definitions/base.js";
import * as ethSendRawTransaction from "../../rpc/actions/eth_sendRawTransaction.js";
import { isEIP155Enforced } from "./is-eip155-enforced.js";

const ethSendRawTransactionSpy = vi.spyOn(
ethSendRawTransaction,
"eth_sendRawTransaction",
);

// skip this test suite if there is no secret key available to test with
// TODO: remove reliance on secret key during unit tests entirely
describe.runIf(process.env.TW_SECRET_KEY)("isEIP155Enforced", () => {
afterAll(() => {
vi.restoreAllMocks();
});

beforeEach(() => {
vi.clearAllMocks();
});

it("should return true if EIP-155 is enforced", async () => {
ethSendRawTransactionSpy.mockRejectedValueOnce({
code: -32003,
message: "eip155",
});

// Call the isEIP155Enforced function with a chain that enforces EIP-155
const result = await isEIP155Enforced({
// optimism enforce eip155
chain: base,
chain: FORKED_ETHEREUM_CHAIN,
client: TEST_CLIENT,
});

Expand All @@ -20,6 +41,11 @@ describe.runIf(process.env.TW_SECRET_KEY)("isEIP155Enforced", () => {
});

it("should return false if EIP-155 is not enforced", async () => {
ethSendRawTransactionSpy.mockRejectedValueOnce({
code: -32003,
message: "Insufficient funds for gas * price + value",
});

// Call the isEIP155Enforced function with a chain that does not enforce EIP-155
const result = await isEIP155Enforced({
// localhost does not enforce eip155
Expand Down
51 changes: 51 additions & 0 deletions packages/thirdweb/src/utils/retry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from "vitest";
import { retry } from "./retry.js";

describe("retry", () => {
it("should successfully resolve the promise without retries if no error is thrown", async () => {
const mockFn = vi.fn().mockResolvedValue("success");
await expect(retry(mockFn, { retries: 1, delay: 100 })).resolves.toBe(
"success",
);
expect(mockFn).toHaveBeenCalledTimes(1);
});

it("should retry the specified number of times on failure", async () => {
const error = new Error("Test error");
const mockFn = vi
.fn()
.mockRejectedValueOnce(error)
.mockRejectedValueOnce(error)
.mockResolvedValue("success");

await expect(retry(mockFn, { retries: 3, delay: 0 })).resolves.toBe(
"success",
);
expect(mockFn).toHaveBeenCalledTimes(3);
});

it("should fail after exceeding the retry limit", async () => {
const error = new Error("Persistent error");
const mockFn = vi.fn().mockRejectedValue(error);

await expect(retry(mockFn, { retries: 2, delay: 0 })).rejects.toThrow();
expect(mockFn).toHaveBeenCalledTimes(2);
});

it("should respect the delay between retries", async () => {
const error = new Error("Test error with delay");
const mockFn = vi
.fn()
.mockRejectedValueOnce(error)
.mockRejectedValueOnce(error)
.mockResolvedValue("success");

const delay = 100;
const startTime = Date.now();
await retry(mockFn, { retries: 3, delay });
const endTime = Date.now();

expect(endTime - startTime).toBeGreaterThanOrEqual(2 * delay);
expect(mockFn).toHaveBeenCalledTimes(3);
});
});
29 changes: 29 additions & 0 deletions packages/thirdweb/src/utils/retry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Attempts to execute a function that returns a promise and retries if the function throws an error.
*
* @param {Function} fn - A function that returns a promise to be executed.
* @param {Object} options - Configuration options for the retry behavior.
* @param {number} [options.retries=1] - The number of times to retry the function before failing.
* @param {number} [options.delay=0] - The delay in milliseconds between retries.
* @returns {Promise<void>} The result of the function execution if successful.
*/

export async function retry<T>(
fn: () => Promise<T>,
options: { retries?: number; delay?: number },
): Promise<T> {
const retries = options.retries ?? 1;
const delay = options.delay ?? 0;
let lastError: Error | null = null;
for (let i = 0; i < retries; i++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
if (delay > 0) {
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
throw lastError;
}