diff --git a/packages/controller/src/__tests__/rate-limit.test.ts b/packages/controller/src/__tests__/rate-limit.test.ts new file mode 100644 index 0000000000..f97489e23e --- /dev/null +++ b/packages/controller/src/__tests__/rate-limit.test.ts @@ -0,0 +1,102 @@ +import { createRateLimitedFetch, parseRetryAfter } from "../rate-limit"; + +describe("rate-limited fetch", () => { + test("retries HTTP 429 responses", async () => { + const sleeps: number[] = []; + const baseFetch = jest + .fn() + .mockResolvedValueOnce(new Response("rate limited", { status: 429 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }))); + const rateLimitedFetch = createRateLimitedFetch( + { + sleep: async (delay) => { + sleeps.push(delay); + }, + random: () => 1, + }, + baseFetch as typeof fetch, + ); + + const response = await rateLimitedFetch("https://rpc.example", { + method: "POST", + }); + + expect(response.status).toBe(200); + expect(baseFetch).toHaveBeenCalledTimes(2); + expect(sleeps).toEqual([500]); + }); + + test("retries JSON-RPC rate-limit errors returned with HTTP 200", async () => { + const baseFetch = jest + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + error: { code: -32005, message: "rate limit exceeded" }, + }), + ), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: "ok" })), + ); + const rateLimitedFetch = createRateLimitedFetch( + { + sleep: async () => {}, + random: () => 1, + }, + baseFetch as typeof fetch, + ); + + const response = await rateLimitedFetch("https://rpc.example", { + method: "POST", + }); + const body = await response.json(); + + expect(body.result).toBe("ok"); + expect(baseFetch).toHaveBeenCalledTimes(2); + }); + + test("honors Retry-After seconds", async () => { + expect(parseRetryAfter("2")).toBe(2000); + }); + + test("does not retry non-idempotent transaction submission methods", async () => { + const baseFetch = jest + .fn() + .mockResolvedValue(new Response("rate limited", { status: 429 })); + const rateLimitedFetch = createRateLimitedFetch( + { + sleep: async () => {}, + random: () => 1, + }, + baseFetch as typeof fetch, + ); + + const response = await rateLimitedFetch("https://rpc.example", { + method: "POST", + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "starknet_addInvokeTransaction", + params: [], + }), + }); + + expect(response.status).toBe(429); + expect(baseFetch).toHaveBeenCalledTimes(1); + }); + + test("does not retry abort errors", async () => { + const error = new DOMException("Aborted", "AbortError"); + const baseFetch = jest.fn().mockRejectedValue(error); + const rateLimitedFetch = createRateLimitedFetch( + {}, + baseFetch as typeof fetch, + ); + + await expect(rateLimitedFetch("https://rpc.example")).rejects.toBe(error); + expect(baseFetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/controller/src/account.ts b/packages/controller/src/account.ts index 9341b0d7e9..0bb74b5e23 100644 --- a/packages/controller/src/account.ts +++ b/packages/controller/src/account.ts @@ -15,6 +15,7 @@ import { } from "./types"; import { AsyncMethodReturns } from "@cartridge/penpal"; import BaseProvider from "./provider"; +import { createRateLimitedFetch } from "./rate-limit"; import { toArray } from "./utils"; import { SIGNATURE } from "@starknet-io/types-js"; @@ -31,8 +32,16 @@ class ControllerAccount extends WalletAccount { options: KeychainOptions, modal: Modal, ) { + const providerOptions = + options?.rpcRetry === false + ? { nodeUrl: rpcUrl } + : { + nodeUrl: rpcUrl, + baseFetch: createRateLimitedFetch(options?.rpcRetry), + }; + super({ - provider: { nodeUrl: rpcUrl }, + provider: providerOptions, walletProvider: provider, address, }); diff --git a/packages/controller/src/index.ts b/packages/controller/src/index.ts index 3740784dd6..f6d5dfa2cd 100644 --- a/packages/controller/src/index.ts +++ b/packages/controller/src/index.ts @@ -4,6 +4,7 @@ export * from "./types"; export * from "./lookup"; export * from "./utils"; export * from "./policies"; +export * from "./rate-limit"; export * from "./wallets"; export * from "./toast"; // @ts-expect-error diff --git a/packages/controller/src/lookup.ts b/packages/controller/src/lookup.ts index 85968fa8ca..7df6a7b9ee 100644 --- a/packages/controller/src/lookup.ts +++ b/packages/controller/src/lookup.ts @@ -7,9 +7,11 @@ import { } from "./types"; import { constants, num } from "starknet"; import { API_URL } from "./constants"; +import { createRateLimitedFetch } from "./rate-limit"; const cache = new Map(); const QUERY_URL = `${API_URL}/query`; +const rateLimitedFetch = createRateLimitedFetch(); type LookupSigner = { isOriginal: boolean; @@ -44,7 +46,7 @@ async function lookup(request: LookupRequest): Promise { return { results: [] }; } - const response = await fetch(`${API_URL}/lookup`, { + const response = await rateLimitedFetch(`${API_URL}/lookup`, { method: "POST", headers: { "Content-Type": "application/json", @@ -62,7 +64,7 @@ async function lookup(request: LookupRequest): Promise { async function queryLookupSigners( username: string, ): Promise { - const response = await fetch(QUERY_URL, { + const response = await rateLimitedFetch(QUERY_URL, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/packages/controller/src/node/account.ts b/packages/controller/src/node/account.ts index 3a8c0a67fe..897be20c40 100644 --- a/packages/controller/src/node/account.ts +++ b/packages/controller/src/node/account.ts @@ -4,6 +4,7 @@ import { Call, InvokeFunctionResponse, WalletAccount } from "starknet"; import { normalizeCalls } from "../utils"; import BaseProvider from "../provider"; +import { createRateLimitedFetch, RateLimitRetryOptions } from "../rate-limit"; export * from "../errors"; export * from "../types"; @@ -24,6 +25,7 @@ export default class SessionAccount extends WalletAccount { guardianKeyGuid, metadataHash, sessionKeyGuid, + rpcRetry, }: { rpcUrl: string; privateKey: string; @@ -35,10 +37,19 @@ export default class SessionAccount extends WalletAccount { guardianKeyGuid: string; metadataHash: string; sessionKeyGuid: string; + rpcRetry?: false | RateLimitRetryOptions; }, ) { + const providerOptions = + rpcRetry === false + ? { nodeUrl: rpcUrl } + : { + nodeUrl: rpcUrl, + baseFetch: createRateLimitedFetch(rpcRetry), + }; + super({ - provider: { nodeUrl: rpcUrl }, + provider: providerOptions, walletProvider: provider, address, }); diff --git a/packages/controller/src/node/provider.ts b/packages/controller/src/node/provider.ts index 096f837491..4e9074aa7d 100644 --- a/packages/controller/src/node/provider.ts +++ b/packages/controller/src/node/provider.ts @@ -9,6 +9,7 @@ import BaseProvider from "../provider"; import { getPresetSessionPolicies, toWasmPolicies } from "../utils"; import { parsePolicies, ParsedSessionPolicies } from "../policies"; import { AuthOptions } from "../types"; +import type { RateLimitRetryOptions } from "../rate-limit"; import { NodeBackend } from "./backend"; export type SessionOptions = { @@ -20,6 +21,7 @@ export type SessionOptions = { basePath: string; keychainUrl?: string; signupOptions?: AuthOptions; + rpcRetry?: false | RateLimitRetryOptions; }; export default class SessionProvider extends BaseProvider { @@ -34,6 +36,7 @@ export default class SessionProvider extends BaseProvider { protected _keychainUrl: string; protected _signupOptions?: AuthOptions; protected _backend: NodeBackend; + protected _rpcRetry?: false | RateLimitRetryOptions; private _readyPromise: Promise; constructor({ @@ -45,6 +48,7 @@ export default class SessionProvider extends BaseProvider { basePath, keychainUrl, signupOptions, + rpcRetry, }: SessionOptions) { super(); @@ -75,6 +79,7 @@ export default class SessionProvider extends BaseProvider { this._keychainUrl = keychainUrl || KEYCHAIN_URL; this._signupOptions = signupOptions; this._backend = new NodeBackend(basePath); + this._rpcRetry = rpcRetry; this._readyPromise = this._resolvePreset(); } @@ -195,6 +200,7 @@ export default class SessionProvider extends BaseProvider { guardianKeyGuid: session.guardianKeyGuid, metadataHash: session.metadataHash, sessionKeyGuid: session.sessionKeyGuid, + rpcRetry: this._rpcRetry, }); return this.account; diff --git a/packages/controller/src/rate-limit.ts b/packages/controller/src/rate-limit.ts new file mode 100644 index 0000000000..79c1c59bf6 --- /dev/null +++ b/packages/controller/src/rate-limit.ts @@ -0,0 +1,229 @@ +export type RateLimitRetryOptions = { + maxAttempts?: number; + baseDelayMs?: number; + maxDelayMs?: number; + maxRetryAfterMs?: number; + sleep?: (delayMs: number, signal?: AbortSignal | null) => Promise; + random?: () => number; +}; + +export const DEFAULT_RATE_LIMIT_RETRY_OPTIONS = { + maxAttempts: 4, + baseDelayMs: 500, + maxDelayMs: 8000, + maxRetryAfterMs: 60000, +} as const; + +const cooldowns = new Map(); +const NON_IDEMPOTENT_RPC_METHODS = new Set([ + "starknet_addInvokeTransaction", + "starknet_addDeclareTransaction", + "starknet_addDeployAccountTransaction", +]); + +type FetchLike = typeof fetch; + +export function createRateLimitedFetch( + options: RateLimitRetryOptions = {}, + baseFetch?: FetchLike, +): FetchLike { + const config = { + ...DEFAULT_RATE_LIMIT_RETRY_OPTIONS, + ...options, + }; + const sleep = options.sleep ?? sleepWithAbort; + const random = options.random ?? Math.random; + + return (async (input: RequestInfo | URL, init?: RequestInit) => { + const signal = + init?.signal ?? (input instanceof Request ? input.signal : null); + const key = requestKey(input); + const skipRetry = hasNonIdempotentJsonRpcMethod(init); + + for (let attempt = 0; attempt < config.maxAttempts; attempt++) { + await waitForCooldown(key, sleep, signal); + + let response: Response; + try { + response = await resolveFetch(baseFetch)(input, init); + } catch (error) { + if (skipRetry || isAbortError(error) || !isRateLimitLikeError(error)) { + throw error; + } + + if (attempt + 1 >= config.maxAttempts) { + throw error; + } + + await sleep(backoffDelay(config, attempt, null, random), signal); + continue; + } + + const retryAfter = parseRetryAfter( + response.headers?.get("Retry-After") ?? null, + ); + const rateLimited = + response.status === 429 || (await hasRateLimitJsonRpcError(response)); + + if (!rateLimited || skipRetry || attempt + 1 >= config.maxAttempts) { + return response; + } + + const delay = backoffDelay(config, attempt, retryAfter, random); + cooldowns.set(key, Date.now() + delay); + await sleep(delay, signal); + cooldowns.delete(key); + } + + return resolveFetch(baseFetch)(input, init); + }) as FetchLike; +} + +export function parseRetryAfter(value: string | null): number | null { + if (!value) { + return null; + } + + const seconds = Number(value); + if (Number.isFinite(seconds)) { + return Math.max(0, seconds * 1000); + } + + const timestamp = Date.parse(value); + if (!Number.isNaN(timestamp)) { + return Math.max(0, timestamp - Date.now()); + } + + return null; +} + +function backoffDelay( + config: Required>, + attempt: number, + retryAfterMs: number | null, + random: () => number, +): number { + const retryAfter = + retryAfterMs === null + ? null + : Math.min(retryAfterMs, config.maxRetryAfterMs); + if (retryAfter !== null) { + return retryAfter; + } + + const exponential = Math.min( + config.baseDelayMs * 2 ** attempt, + config.maxDelayMs, + ); + return Math.floor(random() * exponential); +} + +async function hasRateLimitJsonRpcError(response: Response): Promise { + if (!response.ok) { + return false; + } + + try { + if (typeof response.clone !== "function") { + return false; + } + + const body = await response.clone().json(); + const error = body?.error; + if (!error) { + return false; + } + + return ( + error.code === -32005 || + isRateLimitLikeMessage(error.message) || + isRateLimitLikeMessage(error.data) + ); + } catch { + return false; + } +} + +function isRateLimitLikeError(error: unknown): boolean { + return ( + error instanceof Error && + isRateLimitLikeMessage(`${error.name} ${error.message}`) + ); +} + +function isRateLimitLikeMessage(value: unknown): boolean { + if (typeof value !== "string") { + return false; + } + + const message = value.toLowerCase(); + return ( + message.includes("rate limit") || + message.includes("too many requests") || + message.includes("429") + ); +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === "AbortError"; +} + +function requestKey(input: RequestInfo | URL): string { + if (typeof input === "string") { + return input; + } + if (input instanceof URL) { + return input.toString(); + } + return input.url; +} + +function resolveFetch(baseFetch?: FetchLike): FetchLike { + return baseFetch ?? fetch.bind(globalThis); +} + +function hasNonIdempotentJsonRpcMethod(init?: RequestInit): boolean { + if (typeof init?.body !== "string") { + return false; + } + + try { + const body = JSON.parse(init.body); + const requests = Array.isArray(body) ? body : [body]; + return requests.some((request) => + NON_IDEMPOTENT_RPC_METHODS.has(request?.method), + ); + } catch { + return false; + } +} + +async function waitForCooldown( + key: string, + sleep: Required["sleep"], + signal?: AbortSignal | null, +) { + const until = cooldowns.get(key) ?? 0; + const delay = until - Date.now(); + if (delay > 0) { + await sleep(delay, signal); + } +} + +function sleepWithAbort(delayMs: number, signal?: AbortSignal | null) { + if (signal?.aborted) { + return Promise.reject(new DOMException("Aborted", "AbortError")); + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout(resolve, delayMs); + signal?.addEventListener( + "abort", + () => { + clearTimeout(timeout); + reject(new DOMException("Aborted", "AbortError")); + }, + { once: true }, + ); + }); +} diff --git a/packages/controller/src/session/account.ts b/packages/controller/src/session/account.ts index 99a0acd9ae..f8bea44d3c 100644 --- a/packages/controller/src/session/account.ts +++ b/packages/controller/src/session/account.ts @@ -4,6 +4,7 @@ import { Call, InvokeFunctionResponse, WalletAccount } from "starknet"; import { normalizeCalls } from "../utils"; import BaseProvider from "../provider"; +import { createRateLimitedFetch, RateLimitRetryOptions } from "../rate-limit"; export * from "../errors"; export * from "../types"; @@ -24,6 +25,7 @@ export default class SessionAccount extends WalletAccount { guardianKeyGuid, metadataHash, sessionKeyGuid, + rpcRetry, }: { rpcUrl: string; privateKey: string; @@ -35,10 +37,19 @@ export default class SessionAccount extends WalletAccount { guardianKeyGuid: string; metadataHash: string; sessionKeyGuid: string; + rpcRetry?: false | RateLimitRetryOptions; }, ) { + const providerOptions = + rpcRetry === false + ? { nodeUrl: rpcUrl } + : { + nodeUrl: rpcUrl, + baseFetch: createRateLimitedFetch(rpcRetry), + }; + super({ - provider: { nodeUrl: rpcUrl }, + provider: providerOptions, walletProvider: provider, address, }); diff --git a/packages/controller/src/session/provider.ts b/packages/controller/src/session/provider.ts index 5efb9da98b..b44a73a96b 100644 --- a/packages/controller/src/session/provider.ts +++ b/packages/controller/src/session/provider.ts @@ -12,6 +12,7 @@ import { parsePolicies, ParsedSessionPolicies } from "../policies"; import BaseProvider from "../provider"; import { AuthOptions } from "../types"; import { getPresetSessionPolicies, toWasmPolicies } from "../utils"; +import type { RateLimitRetryOptions } from "../rate-limit"; import SessionAccount from "./account"; interface SessionRegistration { @@ -37,6 +38,7 @@ export type SessionOptions = { keychainUrl?: string; apiUrl?: string; signupOptions?: AuthOptions; + rpcRetry?: false | RateLimitRetryOptions; }; export default class SessionProvider extends BaseProvider { @@ -55,6 +57,7 @@ export default class SessionProvider extends BaseProvider { protected _publicKey!: string; protected _sessionKeyGuid!: string; protected _signupOptions?: AuthOptions; + protected _rpcRetry?: false | RateLimitRetryOptions; private _readyPromise: Promise; public reopenBrowser: boolean = true; @@ -69,6 +72,7 @@ export default class SessionProvider extends BaseProvider { keychainUrl, apiUrl, signupOptions, + rpcRetry, }: SessionOptions) { super(); @@ -101,6 +105,7 @@ export default class SessionProvider extends BaseProvider { this._keychainUrl = keychainUrl || KEYCHAIN_URL; this._apiUrl = apiUrl ?? API_URL; this._signupOptions = signupOptions; + this._rpcRetry = rpcRetry; this._setSigningKeys(); this._readyPromise = this._resolvePreset(); @@ -471,6 +476,7 @@ export default class SessionProvider extends BaseProvider { guardianKeyGuid: sessionRegistration.guardianKeyGuid, metadataHash: sessionRegistration.metadataHash, sessionKeyGuid: sessionRegistration.sessionKeyGuid, + rpcRetry: this._rpcRetry, }); return this.account; diff --git a/packages/controller/src/types.ts b/packages/controller/src/types.ts index be3ea340da..d5024cc466 100644 --- a/packages/controller/src/types.ts +++ b/packages/controller/src/types.ts @@ -13,6 +13,7 @@ import { InvocationsDetails, } from "starknet"; import { KeychainIFrame } from "./iframe"; +import type { RateLimitRetryOptions } from "./rate-limit"; import { AUTH_EXTERNAL_WALLETS, EXTERNAL_WALLETS, @@ -265,6 +266,8 @@ export type Chain = { export type ProviderOptions = { defaultChainId?: ChainId; chains?: Chain[]; + /** Retry RPC/API requests when providers explicitly return rate-limit errors. */ + rpcRetry?: false | RateLimitRetryOptions; }; export type KeychainOptions = IFrameOptions & { @@ -295,6 +298,8 @@ export type KeychainOptions = IFrameOptions & { lazyload?: boolean; /** When true, force WebAuthn operations to run in a popup window instead of the iframe. Useful for development and testing. */ webauthnPopup?: boolean; + /** Retry RPC/API requests when providers explicitly return rate-limit errors. */ + rpcRetry?: false | RateLimitRetryOptions; }; export type ProfileContextTypeVariant = diff --git a/packages/keychain/src/components/provider/index.tsx b/packages/keychain/src/components/provider/index.tsx index a695abd0b8..58ee6a88d2 100644 --- a/packages/keychain/src/components/provider/index.tsx +++ b/packages/keychain/src/components/provider/index.tsx @@ -26,6 +26,9 @@ import { CartridgeAPIProvider } from "@cartridge/controller-ui/utils/api/cartrid import { ErrorBoundary } from "../ErrorBoundary"; import { MarketplaceClientProvider } from "@cartridge/arcade/marketplace/react"; import { SpinnerIcon } from "@cartridge/controller-ui"; +import { createRateLimitedFetch } from "@/utils/rate-limit"; + +const rateLimitedFetch = createRateLimitedFetch(); export function Provider({ children }: PropsWithChildren) { const connection = useConnectionValue(); @@ -42,7 +45,7 @@ export function Provider({ children }: PropsWithChildren) { default: nodeUrl = connection.rpcUrl; } - return { nodeUrl }; + return { nodeUrl, baseFetch: rateLimitedFetch }; }, [connection.rpcUrl, connection.controller]); const defaultChainId = useMemo(() => { diff --git a/packages/keychain/src/hooks/connection.ts b/packages/keychain/src/hooks/connection.ts index d3d71aa579..1a523b8a03 100644 --- a/packages/keychain/src/hooks/connection.ts +++ b/packages/keychain/src/hooks/connection.ts @@ -21,6 +21,7 @@ import { WalletAdapter, WalletBridge, } from "@cartridge/controller"; +import { createRateLimitedFetch } from "@/utils/rate-limit"; import { AsyncMethodReturns } from "@cartridge/penpal"; import { ControllerTheme, @@ -51,6 +52,8 @@ import { import { useSearchParams } from "react-router-dom"; import { SemVer } from "semver"; import { constants, RpcProvider } from "starknet"; + +const rateLimitedFetch = createRateLimitedFetch(); import { ParsedSessionPolicies, parseSessionPolicies } from "./session"; import { storeReferral, @@ -566,7 +569,10 @@ export function useConnectionValue() { useEffect(() => { const fetchChainId = async () => { try { - const provider = new RpcProvider({ nodeUrl: rpcUrl }); + const provider = new RpcProvider({ + nodeUrl: rpcUrl, + baseFetch: rateLimitedFetch, + }); const id = await provider.getChainId(); setChainId(id); } catch (e) { diff --git a/packages/keychain/src/hooks/tokens.tsx b/packages/keychain/src/hooks/tokens.tsx index 45e7408012..ee5185638d 100644 --- a/packages/keychain/src/hooks/tokens.tsx +++ b/packages/keychain/src/hooks/tokens.tsx @@ -3,8 +3,11 @@ import { TokensContext, TokensContextValue, } from "@/components/provider/tokens"; +import { createRateLimitedFetch } from "@/utils/rate-limit"; import { Call, getChecksumAddress, RpcProvider } from "starknet"; +const rateLimitedFetch = createRateLimitedFetch(); + export function useTokens(): TokensContextValue { const context = useContext(TokensContext); if (!context) { @@ -186,7 +189,10 @@ export function useTokenDecimals( const fetchDecimals = async () => { try { - const provider = new RpcProvider({ nodeUrl: rpcUrl }); + const provider = new RpcProvider({ + nodeUrl: rpcUrl, + baseFetch: rateLimitedFetch, + }); const checksumAddress = getChecksumAddress(contractAddress); const result = await provider.callContract({ diff --git a/packages/keychain/src/utils/api/fetcher.tsx b/packages/keychain/src/utils/api/fetcher.tsx index 3c4d8d2ffd..3c94b46ddf 100644 --- a/packages/keychain/src/utils/api/fetcher.tsx +++ b/packages/keychain/src/utils/api/fetcher.tsx @@ -1,5 +1,8 @@ import { useCartridgeAPI } from "@cartridge/controller-ui/utils"; import { getBearerToken } from "@/utils/bearer-token"; +import { createRateLimitedFetch } from "@/utils/rate-limit"; + +const rateLimitedFetch = createRateLimitedFetch(); export function fetchDataCreator( url: string, @@ -14,7 +17,7 @@ export function fetchDataCreator( signal?: AbortSignal, ): Promise => { const bearerToken = getBearerToken(); - const res = await fetch(url, { + const res = await rateLimitedFetch(url, { method: "POST", credentials: options?.credentials || "include", headers: { @@ -29,6 +32,10 @@ export function fetchDataCreator( signal, }); + if (!res.ok) { + throw new Error(`HTTP error! status: ${res.status}`); + } + const json = await res.json(); if (json.errors) { diff --git a/packages/keychain/src/utils/controller.ts b/packages/keychain/src/utils/controller.ts index 23b6bf9b26..7b578a15d8 100644 --- a/packages/keychain/src/utils/controller.ts +++ b/packages/keychain/src/utils/controller.ts @@ -33,6 +33,7 @@ import { import { credentialToAuth } from "@/components/connect/types"; import { ParsedSessionPolicies } from "@/hooks/session"; import { clearBearerToken } from "@/utils/bearer-token"; +import { createRateLimitedFetch } from "@/utils/rate-limit"; import { toWasmPolicies } from "@cartridge/controller"; import { CredentialMetadata } from "@cartridge/controller-ui/utils/api/cartridge"; import { DeployedAccountTransaction } from "@starknet-io/types-js"; @@ -355,6 +356,7 @@ export default class Controller { const controller = Object.create(Controller.prototype) as Controller; controller.provider = new RpcProvider({ nodeUrl: rpcUrl ?? meta.rpcUrl(), + baseFetch: createRateLimitedFetch(), }); controller.cartridgeMeta = meta; controller.cartridge = accountWithMeta.intoAccount(); diff --git a/packages/keychain/src/utils/graphql.ts b/packages/keychain/src/utils/graphql.ts index fb40b52b2c..f3aa6b8cce 100644 --- a/packages/keychain/src/utils/graphql.ts +++ b/packages/keychain/src/utils/graphql.ts @@ -3,11 +3,12 @@ import { type RequestDocument, type Variables, } from "graphql-request"; -import { fetchDataCreator } from "@cartridge/controller-ui/utils"; import { parseClientError, type ErrorWithGraphQL } from "./errors"; import { getBearerToken } from "./bearer-token"; +import { createRateLimitedFetch } from "@/utils/rate-limit"; export const ENDPOINT = `${import.meta.env.VITE_CARTRIDGE_API_URL}/query`; +const rateLimitedFetch = createRateLimitedFetch(); // Read fresh per request — bearer token can appear/change after popup login // without anything calling fetchDataCreator/GraphQLClient again. @@ -16,8 +17,42 @@ function authHeader(): Record { return token ? { Authorization: `Bearer ${token}` } : {}; } +function fetchDataCreator( + url: string, + options?: { headers?: RequestInit["headers"] }, +) { + return async ( + query: string, + variables?: TVariables, + signal?: AbortSignal, + ): Promise => { + const res = await rateLimitedFetch(url, { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + body: JSON.stringify({ query, variables }), + signal, + }); + + if (!res.ok) { + throw new Error(`HTTP error! status: ${res.status}`); + } + + const json = await res.json(); + if (json.errors) { + throw new Error(json.errors[0].message); + } + + return json.data; + }; +} + export const client = new GraphQLClient(ENDPOINT, { credentials: "include", + fetch: rateLimitedFetch, requestMiddleware: (req) => ({ ...req, headers: { ...req.headers, ...authHeader() }, diff --git a/packages/keychain/src/utils/rate-limit.ts b/packages/keychain/src/utils/rate-limit.ts new file mode 100644 index 0000000000..06ae5f2795 --- /dev/null +++ b/packages/keychain/src/utils/rate-limit.ts @@ -0,0 +1,107 @@ +export type RateLimitRetryOptions = { + maxAttempts?: number; + baseDelayMs?: number; + maxDelayMs?: number; + sleep?: (delayMs: number, signal?: AbortSignal | null) => Promise; + random?: () => number; +}; + +const NON_IDEMPOTENT_RPC_METHODS = new Set([ + "starknet_addInvokeTransaction", + "starknet_addDeclareTransaction", + "starknet_addDeployAccountTransaction", +]); + +export function createRateLimitedFetch(options?: RateLimitRetryOptions) { + return localRateLimitedFetch(options); +} + +function localRateLimitedFetch(options: RateLimitRetryOptions = {}) { + const maxAttempts = options.maxAttempts ?? 4; + const baseDelayMs = options.baseDelayMs ?? 500; + const maxDelayMs = options.maxDelayMs ?? 8000; + const sleep = + options.sleep ?? + ((delay: number) => new Promise((r) => setTimeout(r, delay))); + const random = options.random ?? Math.random; + + return (async (input: RequestInfo | URL, init?: RequestInit) => { + const skipRetry = hasNonIdempotentJsonRpcMethod(init); + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const response = await fetch(input, init); + const rateLimited = + response.status === 429 || (await hasRateLimitJsonRpcError(response)); + + if (!rateLimited || skipRetry || attempt + 1 >= maxAttempts) { + return response; + } + + const retryAfter = parseRetryAfter( + response.headers?.get("Retry-After") ?? null, + ); + const delay = + retryAfter ?? + Math.floor(random() * Math.min(baseDelayMs * 2 ** attempt, maxDelayMs)); + await sleep(delay, init?.signal); + } + + return fetch(input, init); + }) as typeof fetch; +} + +function parseRetryAfter(value: string | null): number | null { + if (!value) { + return null; + } + + const seconds = Number(value); + if (Number.isFinite(seconds)) { + return Math.max(0, seconds * 1000); + } + + const timestamp = Date.parse(value); + if (!Number.isNaN(timestamp)) { + return Math.max(0, timestamp - Date.now()); + } + + return null; +} + +async function hasRateLimitJsonRpcError(response: Response) { + if (!response.ok || typeof response.clone !== "function") { + return false; + } + + try { + const body = await response.clone().json(); + const error = body?.error; + if (!error) { + return false; + } + const message = `${error.message ?? ""} ${error.data ?? ""}`.toLowerCase(); + return ( + error.code === -32005 || + message.includes("rate limit") || + message.includes("too many requests") + ); + } catch { + return false; + } +} + +function hasNonIdempotentJsonRpcMethod(init?: RequestInit): boolean { + if (typeof init?.body !== "string") { + return false; + } + + try { + const body = JSON.parse(init.body); + const requests = Array.isArray(body) ? body : [body]; + return requests.some((request) => + NON_IDEMPOTENT_RPC_METHODS.has(request?.method), + ); + } catch { + return false; + } +}