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
102 changes: 102 additions & 0 deletions packages/controller/src/__tests__/rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
11 changes: 10 additions & 1 deletion packages/controller/src/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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,
});
Expand Down
1 change: 1 addition & 0 deletions packages/controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions packages/controller/src/lookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();
const QUERY_URL = `${API_URL}/query`;
const rateLimitedFetch = createRateLimitedFetch();

type LookupSigner = {
isOriginal: boolean;
Expand Down Expand Up @@ -44,7 +46,7 @@ async function lookup(request: LookupRequest): Promise<LookupResponse> {
return { results: [] };
}

const response = await fetch(`${API_URL}/lookup`, {
const response = await rateLimitedFetch(`${API_URL}/lookup`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Expand All @@ -62,7 +64,7 @@ async function lookup(request: LookupRequest): Promise<LookupResponse> {
async function queryLookupSigners(
username: string,
): Promise<LookupSignersQueryResponse> {
const response = await fetch(QUERY_URL, {
const response = await rateLimitedFetch(QUERY_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Expand Down
13 changes: 12 additions & 1 deletion packages/controller/src/node/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -24,6 +25,7 @@ export default class SessionAccount extends WalletAccount {
guardianKeyGuid,
metadataHash,
sessionKeyGuid,
rpcRetry,
}: {
rpcUrl: string;
privateKey: string;
Expand All @@ -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,
});
Expand Down
6 changes: 6 additions & 0 deletions packages/controller/src/node/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -20,6 +21,7 @@ export type SessionOptions = {
basePath: string;
keychainUrl?: string;
signupOptions?: AuthOptions;
rpcRetry?: false | RateLimitRetryOptions;
};

export default class SessionProvider extends BaseProvider {
Expand All @@ -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<void>;

constructor({
Expand All @@ -45,6 +48,7 @@ export default class SessionProvider extends BaseProvider {
basePath,
keychainUrl,
signupOptions,
rpcRetry,
}: SessionOptions) {
super();

Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading