@mik3fly-lab/etherscan-broker
v0.2.0
Published
Adaptive Etherscan API key broker: rolling-window admission, health-weighted key selection and transparent replay across a pool of keys.
Maintainers
Readme
@mik3fly-lab/etherscan-broker
Distribue les appels Etherscan sur un pool de clés API, en mesurant en continu ce que chaque clé accepte réellement.
Pourquoi
Un round-robin envoie le même volume à chaque clé sans jamais regarder si elle peut l'absorber. Mesuré en production le 2026-08-08 sur un pool de 9 clés : 29,5 % des appels rejetés pour 9 % d'utilisation de la capacité théorique. Les rate limits étaient fabriqués par le répartiteur, pas subis.
Deux constats ont façonné ce module :
Le plafond réel n'est pas celui qu'on croit. Etherscan annonce Max calls per sec rate limit reached (3/sec). Le code visait 5/sec sur la foi d'un commentaire erroné — 67 % au-dessus, donc ~2 appels sur 5 rejetés dès qu'une file se formait. Le broker lit le plafond dans le message d'erreur et s'y aligne.
Le pool n'est pas homogène, et le classement bouge. À un instant donné une partie des clés est étranglée et l'autre non — mais à 5 heures d'écart, sur les mêmes clés :
| Clé | Matin (1 250 appels) | Après-midi | |---|---|---| | A | 0,0 % | 50,0 % | | B | 0,0 % | 25,0 % | | C | 0,0 % | 15,2 % | | D | 49,5 % | 12,8 % | | E | 44,5 % | 100,0 % |
Coder en dur « quelle clé est bonne » ne tient donc pas. Seule une mesure continue suit le classement.
Après déploiement, sur une fenêtre propre de 90 requêtes : échecs vus par l'appelant 31,5 % → 4,4 %, et le volume suivait la santé à l'inverse — la clé à 100 % d'erreur recevait 6 dispatches, celle à 12,8 % en recevait 39, sans rien de codé en dur.
Trois garanties
Admission — un appel n'est lancé que si le seau de la clé choisie a un jeton libre dans sa fenêtre glissante d'une seconde et que son dernier appel date d'au moins 1000/callsPerSec ms (+25 ms). Les appels sont espacés, jamais envoyés en rafale : mesuré en prod le 2026-09-27, 3 appels partis dans la même seconde sur une clé à 3/sec échouent à 22 % (la fenêtre d'Etherscan n'est pas alignée sur la nôtre, et deux proxies n'ont pas la même latence).
Groupes — certaines clés sont comptées ensemble par Etherscan (même compte). Mesuré le 2026-09-27 sur 3 clés d'un même groupe : 1-2 appels/sec cumulés → 100 % de succès, 3/sec → 76 %, 9/sec → 33 % (≈ 3 réussis par seconde pour les trois). L'option groups leur donne un seul seau : même plafond, même espacement, même cooldown sur dépassement. capacity() ne compte le groupe qu'une fois.
Équilibrage — la clé retenue est la plus saine (échecs des 5 dernières minutes), puis la moins sollicitée (5 min, départagée sur 15 min). Une clé qui rejette devient un débordement, pas un pair. Si les clés saines couvrent la demande, le broker préfère attendre une clé saine au plus 1,5 s, et seulement une clé que l'appel n'a pas déjà essayée. (v0.1.0 n'avait aucune de ces deux bornes : un appel rejoué en tête de file figeait toute la file jusqu'à 5 min, puis la vidait en rafale.)
Résilience — sur échec, la clé passe en cooldown et l'appel est rejoué sur une autre clé. L'appelant ne voit une erreur que si tout le pool est hors service.
Traitement par classe d'erreur
Les trois messages d'Etherscan contiennent tous rate limit et sont couramment confondus, alors qu'ils appellent des réactions opposées :
| Message | Nature | Réaction |
|---|---|---|
| Max calls per sec rate limit reached (N/sec) | transitoire | recalibre la clé sur N, cooldown 1 s |
| Max daily rate limit reached... day/limit | jusqu'à minuit UTC | clé garée pour la journée |
| Invalid API Key | permanent | clé désactivée + événement |
| réseau / 5xx | transitoire | backoff exponentiel, plafonné à 30 s |
| EtherscanFatalError | déterministe | pas de replay, la clé n'est pas mise en cause |
Utilisation
import { createBroker, classifyQuotaError, EtherscanQuotaError, applyKeySlice }
from "@mik3fly-lab/etherscan-broker";
const broker = createBroker<MyResponse>({
keys: applyKeySlice(allKeys, process.env.ETHERSCAN_KEY_SLICE),
baseUrls: ["https://api.etherscan.io/v2/api"],
transport: async (key, baseUrl, module, action, params) => { /* votre fetch */ },
onEvent: e => console.log(JSON.stringify(e)),
});
const data = await broker.call("contract", "getsourcecode", { address });Le broker ne connaît ni HTTP ni votre format de log : le transport et les événements sont injectés. À vous de traduire les réponses d'erreur en EtherscanQuotaError (via classifyQuotaError) pour qu'il puisse réagir par classe.
applyKeySlice(keys, spec)
Etherscan compte le rate limit par clé, pas par appelant : deux services sur le même pool additionnent leur débit sur chaque clé. ETHERSCAN_KEY_SLICE dédie une tranche à un service — "1-5", "1,3,7", ou un mélange, 1-indexé. Absent ou vide : tout le pool. Tranche qui ne sélectionne rien : repli sur tout le pool, jamais un service sans clé.
Entrelacer, ne pas concentrer. Comme le classement des clés bouge, une tranche contiguë est un pari ; une tranche entrelacée reste correcte quel que soit l'état du jour.
applyKeyGroups(keys, spec)
Construit l'option groups depuis une variable d'environnement (ETHERSCAN_KEY_GROUPS) : même syntaxe que la tranche, indices du pool complet, groupes séparés par ; ("4-9", "4-6;7,8"). Aucun repli sur tout le pool, et un groupe d'une clé est ignoré.
Observabilité
broker.stats() retourne, par clé : plafond courant, usage 5 min / 15 min, échecs 5 min, compteur journalier, cooldown restant, compteurs ok/échec. broker.capacity() donne le débit théorique du pool vivant.
Le quota journalier (100 K/clé par défaut) est le plafond suivant une fois le débit par seconde maîtrisé — d'où le compteur.
Options
| Option | Défaut | Rôle |
|---|---|---|
| keys | — | clés du pool |
| baseUrls | — | sorties, distribuées entre les clés |
| transport | — | l'aller-retour HTTP |
| callsPerSec | 3 | plafond initial par clé, recalibré ensuite |
| dailyCap | 100000 | quota journalier par clé (arrêt à 95 %) |
| maxAttempts | 3 | tentatives par appel, clés distinctes autant que possible |
| groups | [] | listes de clés partageant un seul seau (même compte Etherscan) |
| onEvent | — | flux d'événements pour les logs |
