ethers.js – TypeScript библиотека для доступа к EVM блокчейнам. Я использую ее в своих проектах, и другими авторами по ней уже написаны хорошие статьи на хабре (основы, отправка транзакций). В текущей статье хочу поделиться опытом оптимизации запросов ethers js при работе с различными облачными провайдерами JSON-RPC (Alchemy, Infura) и self-hosted нодой. Расскажу о случаях, когда приложение может просто перестать работать, и что с этим делать. Приведу прикидки производительности.
В статье используется ethers.js версии 6.11, которая обратно не совместима с пятой версией из статей, приведенных выше, хотя различия довольно тривиальные и программы портируются легко.
Проблема
Нужно было реализовать фичу, которая выгружает в CSV балансы в USDT 0xc2132D05D31c914a87C6611C10748AEb04B58e8F на Полигоне некоторого количества кошельков (в тестовых данных было около 30ти). Первая реализация была примерно такая (wallets – кошельки для проверки):
const provider = new ethers.JsonRpcProvider(rpcUrl);
wallets.map(async (wallet) => {
const contract = new ethers.Contract(
"0xc2132D05D31c914a87C6611C10748AEb04B58e8F",
abi,
provider
);
const bal = await contract.balanceOf(wallet);
console.log(`Wallet: ${wallet}, Balance: ${bal}`);
});
В качестве rpcUrl использовался бесплатный от Infura. В итоге получил зависание на 5 минут (!) и потом ошибку (сокращено для наглядности):
JsonRpcProvider failed to detect network and cannot start up; retry in 1s (perhaps the URL is wrong or the node is not started)
Error: exceeded maximum retry limit (request={ }, response={ }, error=null, code=SERVER_ERROR, version=6.6.7) at makeError
Сразу возникли 2 вопроса:
Почему так долго?
Почему появился retry limit на 30ти запросах?
Решение 1 – самостоятельный батчинг
Первой попыткой решения был батчинг запросов. Ethereum JSON-RPC поддерживает отправку нескольких Ethereum запросов в одном HTTP запросе. Так это выглядит через вызов curl – запрос баланса USDT на Полигоне двух кошельков 0x2066fc1cac710806c8ffcc8539ec69d6ce2ee489 и 0x5f4d4b6b59a88fd83aa754bdd12afc73f53830ec.
curl https://ethnode.mydomain.com \
-X POST \
-H "Content-Type: application/json" \
-d '[
{
"jsonrpc": "2.0",
"method": "eth_call",
"params": [
{
"to": "0xc2132D05D31c914a87C6611C10748AEb04B58e8F",
"data": "0x70a082310000000000000000000000002066fc1cac710806c8ffcc8539ec69d6ce2ee489"
},
"latest"
],
"id": 1
},
{
"jsonrpc": "2.0",
"method": "eth_call",
"params": [
{
"to": "0xc2132D05D31c914a87C6611C10748AEb04B58e8F",
"data": "0x70a082310000000000000000000000005f4d4b6b59a88fd83aa754bdd12afc73f53830ec"
},
"latest"
],
"id": 1
}
]
'
В ethers.js это можно сделать так (некоторые детали и импорты опущены):
const createPayload = async (
token: string,
wallet: string,
provider: ethers.JsonRpcProvider
) => {
const contract = new ethers.Contract(token, abi, provider);
const byContract = await contract.balanceOf.populateTransaction(wallet);
return {
method: "eth_call",
id: 1,
params: [byContract, "latest"],
jsonrpc: "2.0",
} as JsonRpcPayload;
};
const provider = new ethers.JsonRpcProvider(rpcUrl);
const payloads = await Promise.all(
wallets.map(async (wallet) => {
return {
payload: createPayload(
"0xc2132D05D31c914a87C6611C10748AEb04B58e8F",
wallet,
provider
),
wallet: wallet,
};
})
);
const res = await provider._send(
await Promise.all(payloads.map((p) => p.payload))
);
// print res
Используется функция JsonRpcProvider._send, которая принимает массив ethers.JsonRpcPayload и отправляет это в одном HTTP запросе. По сути, это то же самое, что вызов curl выше, просто создано программно.
contract.balanceOf.populateTransaction(wallet) – это удобный способ, чтобы сконструировать calldata, состоящая из сигнатуры функции balanceOf (0x70a08231) и адреса кошелька.
Запустив код выше с 30ю кошельками, получил ту же самую ошибку ("exceeded maximum retry limit") с зависанием на 5 минут. Путем сокращения количества кошельков было найдено ограничение в 10 JSON-RPC запросов в одном HTTP запросе на бесплатном плане Infura.
Решил попробовать сравнить с бесплатным Alchemy и self-hosted нодой эфира на Geth:
Infura free |
Alchemy free |
Собственная нода (в моем городе) |
|
Лимит кол-ва запросов в батче |
10 |
1000 |
1000 |
Ошибка при превышении лимита |
HTTP 429 Too Many Requests -32005 batch item count exceeded |
HTTP 400 Bad Request -32600 Sorry, the maximum batch request size is 1000 |
HTTP 200 -32600 batch too large |
Поведение ethers.js по умолчанию |
Зависание 5 минут |
Мгновенное исключение |
Мгновенное исключение |
Время отклика на лимитном размере батча, сек. |
0,85 |
3,1 |
0,75 |
Время отклика на одном батче в 10 запросов, сек. |
0,85 |
0,6 |
0,1 |
Также, когда отправлял на свою ноду большой батч (более 5200 запросов), получал HTTP 413 Request Entity Too Large.
Решение 2 – батчинг от ethers
Стало понятно, что разные провайдеры по-разному отправляют ошибки и на них по-разному реагирует ethers. Начал думать, как можно разобраться со всем этим безобразием. Ведь не хочется, чтобы программа зависла на 5 минут, а также не хотелось писать свой код, чтобы разбивать запросы на батчи или что-то подобное. Начал смотреть, есть ли что-то на эту тему в ethers. Тестировал на Infura, тк самые строгие лимиты.
Оказывается, по умолчанию ethers делает батчинг запросов через JsonRpcApiProviderOptions, которые можно указать третьим параметром при создании JsonRpcProvider. Интересные параметры это:
batchStallTime – сколько времени (в мс) добавлять запросы в текущий батч (и потом отправлять его). По умолчанию 10 мс.
batchMaxSize – максимальный размер батч запроса в байтах (по умолчанию 1 МБ).
batchMaxCount – максимальное кол-во JSON-RPC запросов в одном HTTP батче (по умолчанию 100).
Думаю, вы уже догадались, что нужно сделать, чтобы Infura начала работать. batchMaxCount установить в 10 (макс число запросов в одном батче), и увеличить batchStallTime, чтобы реже бомбардировать сервер запросами. У меня получалось обработать 1000 кошельков с параметрами batchMaxCount: 10, batchStallTime от 100. Непредсказуемо, какое значение batchStallTime может вызвать throttling (429 код), нужно подбирать исходя из текущей ситуации / ноды, также оно зависит от других параллельных запросов. Время выполнения balanceOf на 1000 кошельков варьировалось от 2 до 60 секунд (немалый разбег!).
const req = new ethers.FetchRequest(rpcUrl);
req.timeout = 10000;
req.setThrottleParams({
maxAttempts: 12,
});
const provider = new ethers.JsonRpcProvider(req, undefined, {
batchMaxCount: 10,
batchStallTime: 100,
});
await Promise.all(
wallets.map(async (wallet) => {
const contract = new ethers.Contract(
"0xc2132D05D31c914a87C6611C10748AEb04B58e8F",
abi,
provider
);
const bal = await contract.balanceOf(wallet);
console.log(`Wallet: ${wallet}, Balance: ${bal}`);
})
);
Еще параметры, которые влияют на выполнение HTTP запросов, их можно менять через FetchRequest:
timeout (в мс) - по истечению этого параметра запрос генерируется исключение. По умолчанию он 300 000 (5 минут).
-
maxAttempts – максимальное количество попыток запроса к серверу в случае 429 ошибки (throttling) (по умолчанию 12). Если это 1, то HTTP запрос не повторяется в случае 429 ошибки. Интересно, что в ethers 6.11 был баг – этот параметр не учитывался, и только я полез сделать PR, как обнаружил, что на гитхабе это уже поправлено, и выйдет, видимо, в 6.12 :)
Думаю, стало понятно, почему в начале было зависание на примерно 5 минут и ошибки exceeded maximum retry limit. ethers пытался отправить батчи, получал 429, повторял еще раз, и так 12 раз с определенным backoff.
Выводы
Главный вывод – разные облачные сервисы имеют разные параметры (в платной версии будет то же самое), и, если вы разрабатываете серьезное приложение, нужно определиться с инфраструктурой и затачивать приложение под нее. Быстро менять поставщиков не получится, несмотря на, кажется, универсальный интерфейс и абстракцию в виде ethers.
То же самое относится к библиотеке, ethers постоянно дорабатывается, поэтому обновление до новой версии, даже минорной, имеет высокий шанс что-нибудь сломать. Лучше провести полное тестирование приложения, особенно там где интенсивный обмен данных с JSON RPC близко к лимитам.
Также, в приложении, вероятно, потребуется конфигурирование разных ethers провайдеров для разных юзкейсов, это тоже стоит учесть в архитектуре.
Конечно, self-hosted нода имеет самый лучшие времена отклика за счет размещения в своем датацентре, что может быть полезно для высоконагруженных систем, но косты ее поддержки довольно высоки.
Код на Github https://github.com/TechGeorgii/ethers.js-performance
Минутка PR. Веду тг‑канал Web3 разработчик. Пишу небольшие заметки (не часто) о задачах по блокчейну/крипте, которые решаю. Буду рад видеть среди подписчиков!
Комментарии (5)
vicsoftware
17.04.2024 04:33"Путем сокращения количества кошельков было найдено ограничение в 10 JSON-RPC запросов в одном HTTP запросе на бесплатном плане Infura."
У Infura ограничение на 10 запросов в течение, кажется, 10 секунд. Потом она начинает сыпать ошибками, пока временной период не закончится.
root85 Автор
17.04.2024 04:33Да, throttling тоже есть. Количество HTTP запросов в секунду не тестил, писал про ограничение в 10 JSON-RPC запросов в одном HTTP запросе (батче).
pnaydanovgoo
17.04.2024 04:33Стоит такую работу уносить на бекенд и пусть он там синхронизирует в фоне? А на клиента отдавать готовые данные. Или если нет бекенда в проекте, то использовать индексер, например тот же TheGraph.
Dmi1988
Почему бы для начала из map не убрать Коннект к контракту?
root85 Автор
Вы имеете в виду вызов new ethers.Contract(...) ? Это конструктор, который инициализирует объект контракта, создает его свойства, но не обращается к RPC, поэтому картина не поменяется.