diff --git a/packages/integration-client/src/config/sdkConfig.ts b/packages/integration-client/src/config/sdkConfig.ts new file mode 100644 index 0000000..525e09c --- /dev/null +++ b/packages/integration-client/src/config/sdkConfig.ts @@ -0,0 +1,15 @@ +export interface RetryConfig { + maxAttempts: number; + baseDelayMs: number; + maxDelayMs: number; +} + +export const defaultRetryConfig: RetryConfig = { + maxAttempts: 3, + baseDelayMs: 500, + maxDelayMs: 5000, +}; + +export const sdkConfig = { + rpcRetry: defaultRetryConfig, +}; diff --git a/packages/integration-client/src/contracts/contractClient.ts b/packages/integration-client/src/contracts/contractClient.ts index 5638d56..26a210a 100644 --- a/packages/integration-client/src/contracts/contractClient.ts +++ b/packages/integration-client/src/contracts/contractClient.ts @@ -1,5 +1,7 @@ import { HttpClient } from "../http/httpClient.js"; import type { ContractCallOptions, JsonRpcRequest, JsonRpcResponse } from "./contract.types.js"; +import { withRetry } from "./contractHelpers.js"; +import { sdkConfig } from "../config/sdkConfig.js"; /** * Thin JSON-RPC client for on-chain reads, used through @@ -42,26 +44,28 @@ export class ContractClient { id: Date.now(), }; - const res = await this.httpClient.request(this.rpcUrl, { - ...options, - method: "POST", - headers: { - "Content-Type": "application/json", - ...options.headers, - }, - body: JSON.stringify(body), - }); + return withRetry(async () => { + const res = await this.httpClient.request(this.rpcUrl, { + ...options, + method: "POST", + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + body: JSON.stringify(body), + }); - if (!res.ok) { - throw new Error(`RPC_HTTP_ERROR:${res.status}`); - } + if (!res.ok) { + throw new Error(`RPC_HTTP_ERROR:${res.status}`); + } - const data: JsonRpcResponse = await res.json(); + const data: JsonRpcResponse = await res.json(); - if (data.error) { - throw new Error(`RPC_ERROR:${data.error.code} ${data.error.message}`); - } + if (data.error) { + throw new Error(`RPC_ERROR:${data.error.code} ${data.error.message}`); + } - return data.result as T; + return data.result as T; + }, sdkConfig.rpcRetry); } } diff --git a/packages/integration-client/src/contracts/contractHelpers.ts b/packages/integration-client/src/contracts/contractHelpers.ts new file mode 100644 index 0000000..7cca1b3 --- /dev/null +++ b/packages/integration-client/src/contracts/contractHelpers.ts @@ -0,0 +1,78 @@ +import { ErrorCodes } from "../errors/errorCodes.js"; + +export interface RetryOptions { + maxAttempts?: number; + baseDelayMs?: number; + maxDelayMs?: number; +} + +export function isRetryableError(error: any): boolean { + if (!error) return false; + + if ( + error.code === ErrorCodes.TIMEOUT_ERROR || + error.code === ErrorCodes.NETWORK_ERROR || + error.code === ErrorCodes.RATE_LIMIT_EXCEEDED + ) { + return true; + } + + const msg = error.message || ""; + + // Checking for standard HTTP transient errors if thrown as RPC_HTTP_ERROR + if ( + msg.includes("RPC_HTTP_ERROR:429") || + msg.includes("RPC_HTTP_ERROR:408") || + msg.includes("RPC_HTTP_ERROR:500") || + msg.includes("RPC_HTTP_ERROR:502") || + msg.includes("RPC_HTTP_ERROR:503") || + msg.includes("RPC_HTTP_ERROR:504") + ) { + return true; + } + + if ( + msg.includes("ECONNRESET") || + msg.includes("ETIMEDOUT") || + msg.includes("timeout") || + error.name === "TimeoutError" || + msg.includes("rate limit") || + msg.includes("-32005") || // RPC rate limit + msg.includes("-32004") || // RPC timeout + msg.includes("-32042") || // RPC timeout/rate limit variations + msg.includes("-32043") + ) { + return true; + } + + return false; +} + +export async function withRetry( + fn: () => Promise, + options: RetryOptions = {} +): Promise { + const maxAttempts = options.maxAttempts ?? 3; + const baseDelayMs = options.baseDelayMs ?? 500; + const maxDelayMs = options.maxDelayMs ?? 5000; + + let attempt = 1; + + while (true) { + try { + return await fn(); + } catch (error) { + if (attempt >= maxAttempts || !isRetryableError(error)) { + throw error; + } + + // Exponential backoff with jitter + const delay = Math.min(baseDelayMs * Math.pow(2, attempt - 1), maxDelayMs); + const jitter = delay * 0.2 * Math.random(); + const waitTime = delay + jitter; + + await new Promise((resolve) => setTimeout(resolve, waitTime)); + attempt++; + } + } +} diff --git a/packages/integration-client/src/index.ts b/packages/integration-client/src/index.ts index 00b8cd5..04e54ea 100644 --- a/packages/integration-client/src/index.ts +++ b/packages/integration-client/src/index.ts @@ -10,6 +10,8 @@ export type { } from "./http/http.types.js"; export * from "./contracts/contract.types.js"; export { ContractClient } from "./contracts/contractClient.js"; +export * from "./contracts/contractHelpers.js"; +export { sdkConfig, defaultRetryConfig } from "./config/sdkConfig.js"; export { upcastActivityEvent, upcastActivityEvents, diff --git a/packages/integration-client/test/contracts.test.ts b/packages/integration-client/test/contracts.test.ts index 30a3093..4b34b93 100644 --- a/packages/integration-client/test/contracts.test.ts +++ b/packages/integration-client/test/contracts.test.ts @@ -55,3 +55,66 @@ test("ContractClient - retry on transient RPC errors (HTTP 500)", async () => { assert.strictEqual(result, "ok"); assert.strictEqual(attempts, 2); }); + +test("ContractClient - retry on transient JSON-RPC error", async () => { + let attempts = 0; + const mockFetch = async () => { + attempts++; + if (attempts < 2) { + return new Response(JSON.stringify({ + jsonrpc: "2.0", + id: 1, + error: { code: -32005, message: "rate limit exceeded" } + }), { status: 200 }); // note: HTTP 200, but RPC error + } + return new Response(JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: "success" + })); + }; + + const client = new IntegrationClient({ + baseUrl: "http://api", + transport: { + fetch: mockFetch as any + } + }); + + // Since we use the module's default config for now, it should retry. + const contract = client.getContractClient("http://rpc"); + const result = await contract.call("test", []); + + assert.strictEqual(result, "success"); + assert.strictEqual(attempts, 2); +}); + +test("ContractClient - no retry on permanent JSON-RPC error", async () => { + let attempts = 0; + const mockFetch = async () => { + attempts++; + return new Response(JSON.stringify({ + jsonrpc: "2.0", + id: 1, + error: { code: 3, message: "execution reverted" } + }), { status: 200 }); + }; + + const client = new IntegrationClient({ + baseUrl: "http://api", + transport: { + fetch: mockFetch as any + } + }); + + const contract = client.getContractClient("http://rpc"); + + try { + await contract.call("test", []); + assert.fail("Should have thrown"); + } catch (err: any) { + assert.match(err.message, /RPC_ERROR:3 execution reverted/); + } + + assert.strictEqual(attempts, 1); +}); diff --git a/scripts/check-scripts.js b/scripts/check-scripts.js new file mode 100644 index 0000000..fe96848 --- /dev/null +++ b/scripts/check-scripts.js @@ -0,0 +1,46 @@ +const fs = require('fs'); +const path = require('path'); + +function findPackageJsonFiles(dir, fileList = []) { + const files = fs.readdirSync(dir); + for (const file of files) { + if (file === 'node_modules' || file === 'dist' || file === '.git' || file === '.next') continue; + const filePath = path.join(dir, file); + if (fs.statSync(filePath).isDirectory()) { + findPackageJsonFiles(filePath, fileList); + } else if (file === 'package.json') { + fileList.push(filePath); + } + } + return fileList; +} + +const packageFiles = findPackageJsonFiles(process.cwd()); +const report = []; + +packageFiles.forEach(file => { + const content = fs.readFileSync(file, 'utf-8'); + const pkg = JSON.parse(content); + + const scripts = pkg.scripts || {}; + for (const [name, cmd] of Object.entries(scripts)) { + if (['build', 'typecheck', 'lint'].includes(name)) { + if (cmd.match(/(tsc|next build|docusaurus build).*(pnpm|npm|yarn|eslint)/) && !cmd.includes('-w @guildpass/env')) { + report.push({ file: path.relative(process.cwd(), file), script: name, content: cmd }); + } + // Specific check for the known bad pattern + if (cmd.includes('tsc -p tsconfig.json pnpm start pnpm typecheck pnpm lint')) { + report.push({ file: path.relative(process.cwd(), file), script: name, content: cmd }); + } + } + } +}); + +console.log('--- Malformed Scripts Report ---'); +if (report.length === 0) { + console.log('No malformed scripts found! All build, typecheck, and lint scripts are clean and single-purpose.'); +} else { + report.forEach(r => { + console.log(`${r.file} -> [${r.script}]: "${r.content}"`); + }); +}