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
15 changes: 15 additions & 0 deletions packages/integration-client/src/config/sdkConfig.ts
Original file line number Diff line number Diff line change
@@ -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,
};
38 changes: 21 additions & 17 deletions packages/integration-client/src/contracts/contractClient.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<T> = await res.json();
const data: JsonRpcResponse<T> = 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);
}
}
78 changes: 78 additions & 0 deletions packages/integration-client/src/contracts/contractHelpers.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
fn: () => Promise<T>,
options: RetryOptions = {}
): Promise<T> {
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++;
}
}
}
2 changes: 2 additions & 0 deletions packages/integration-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
63 changes: 63 additions & 0 deletions packages/integration-client/test/contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
46 changes: 46 additions & 0 deletions scripts/check-scripts.js
Original file line number Diff line number Diff line change
@@ -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}"`);
});
}