Skip to content
Open
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
10 changes: 9 additions & 1 deletion src/routes/adl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
createLogger,
sanitizeSlabAddress,
} from "@percolator/shared";
import { withRpcTimeout, RpcTimeoutError } from "../utils/rpc-timeout.js";
import { isBlockedSlab } from "../middleware/validateSlab.js";

const logger = createLogger("api:adl");
Expand Down Expand Up @@ -173,8 +174,15 @@ export function adlRoutes(): Hono {
const connection = getConnection();
let data: Uint8Array;
try {
data = await fetchSlab(connection, new PublicKey(slab));
data = await withRpcTimeout(
fetchSlab(connection, new PublicKey(slab)),
`fetchSlab(${slab})`,
);
} catch (err) {
if (err instanceof RpcTimeoutError) {
logger.warn("RPC timeout fetching slab for ADL", { slab, timeoutMs: err.timeoutMs });
return c.json({ error: "Upstream RPC timeout", slab }, 504);
}
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("not found")) {
return c.json({ error: "Slab account not found", slab }, 404);
Expand Down
3 changes: 2 additions & 1 deletion src/routes/health.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Hono } from "hono";
import { getConnection, getSupabase, createLogger, truncateErrorMessage } from "@percolator/shared";
import { withRpcTimeout, HEALTH_RPC_TIMEOUT_MS } from "../utils/rpc-timeout.js";
import { getWebSocketMetrics } from "./ws.js";
import { requireApiKey } from "../middleware/auth.js";

Expand All @@ -26,7 +27,7 @@ export function healthRoutes(): Hono {

// Check RPC connectivity
try {
await getConnection().getSlot();
await withRpcTimeout(getConnection().getSlot(), "healthcheck:getSlot", HEALTH_RPC_TIMEOUT_MS);
checks.rpc = true;
} catch (err) {
logger.error("RPC check failed", { error: truncateErrorMessage(err instanceof Error ? err.message : err, 120) });
Expand Down
10 changes: 9 additions & 1 deletion src/routes/markets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { cacheMiddleware } from "../middleware/cache.js";
import { withDbCacheFallback } from "../middleware/db-cache-fallback.js";
import { fetchSlab, parseHeader, parseConfig, parseEngine } from "@percolator/sdk";
import { getConnection, getSupabase, getNetwork, createLogger, sanitizeSlabAddress, truncateErrorMessage } from "@percolator/shared";
import { withRpcTimeout, RpcTimeoutError } from "../utils/rpc-timeout.js";

const logger = createLogger("api:markets");

Expand Down Expand Up @@ -123,7 +124,10 @@ export function marketRoutes(): Hono {
try {
const connection = getConnection();
const slabPubkey = new PublicKey(slab);
const data = await fetchSlab(connection, slabPubkey);
const data = await withRpcTimeout(
fetchSlab(connection, slabPubkey),
`fetchSlab(${slab})`,
);
const header = parseHeader(data);
const cfg = parseConfig(data);
const engine = parseEngine(data);
Expand All @@ -150,6 +154,10 @@ export function marketRoutes(): Hono {
},
});
} catch (err) {
if (err instanceof RpcTimeoutError) {
logger.warn("RPC timeout fetching market", { slab, timeoutMs: err.timeoutMs });
return c.json({ error: "Upstream RPC timeout" }, 504);
}
const detail = err instanceof Error ? err.message : "Unknown error";
const isNotFound = detail.includes("not found") || detail.includes("Account does not exist");
if (isNotFound) {
Expand Down
41 changes: 41 additions & 0 deletions src/utils/rpc-timeout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Timeout wrapper for RPC calls that don't accept AbortSignal.
*
* fetchSlab() and getConnection().getSlot() from the SDK/shared libs take a
* Connection object, not an AbortSignal, so AbortSignal.timeout() cannot be
* threaded through. Promise.race is the only viable approach.
*
* The underlying RPC call is NOT cancelled — Node will GC the dangling promise
* once it settles. This is acceptable because fetchSlab/getSlot are read-only.
*/

const DEFAULT_RPC_TIMEOUT_MS = 10_000;
const DEFAULT_HEALTH_RPC_TIMEOUT_MS = 5_000;

export const RPC_TIMEOUT_MS: number =
Number(process.env.RPC_TIMEOUT_MS) || DEFAULT_RPC_TIMEOUT_MS;

export const HEALTH_RPC_TIMEOUT_MS: number =
Number(process.env.HEALTH_RPC_TIMEOUT_MS) || DEFAULT_HEALTH_RPC_TIMEOUT_MS;

export class RpcTimeoutError extends Error {
public readonly timeoutMs: number;

constructor(operation: string, timeoutMs: number) {
super(`RPC timeout: ${operation} did not complete within ${timeoutMs}ms`);
this.name = "RpcTimeoutError";
this.timeoutMs = timeoutMs;
}
}

export function withRpcTimeout<T>(
promise: Promise<T>,
operation: string,
timeoutMs: number = RPC_TIMEOUT_MS,
): Promise<T> {
let timer: ReturnType<typeof setTimeout>;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new RpcTimeoutError(operation, timeoutMs)), timeoutMs);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer!));
}
Loading