UNPKG

0xweb

Version:

Contract package manager and other web3 tools

354 lines (311 loc) 10.7 kB
import { $date } from '@dequanto/utils/$date'; import { l } from '@dequanto/utils/$logger'; import { $promise } from '@dequanto/utils/$promise'; import alot from 'alot'; import memd from 'memd'; let rateLimitRgx = { checks: [ // extracted from known and common rate-limit messages /rate.+limit/i, /too.+many.+request/i, /exceed.+request/i, /try.+later/i, /request.+rate/i, /\b429\b/i, [/\bCUPS\b/i, /(\b(maximum|limit)\b)i/] ], extracts: [ // 100 per 1 minute /(?<limit>\d+)\s*(per|\/)\s*(?<periodValue>\d+)?\s*(?<periodName>[msdh])/i, ], knownFields: { allowed_rps (val: number) { val = Number(val); return { spanMs: val * 1000, spanCount: val }; } } } let batchLimitRgx = { extracts: [ /batch\s+limit\s+(?<batchLimit>\d+)/i, ], } type TRateLimitError = Error & { data?: { details?: string data?: any } }; export class RateLimitGuard { static isBatchLimit(error: Error) { return batchLimitRgx.extracts.some(x => x.test(error.message)); } static extractBatchLimitFromError (error: Error): number { for (let rgx of batchLimitRgx.extracts) { let val = rgx.exec(error.message)?.groups?.batchLimit; if (val != null) { return Number(val); } } return null; } static isRateLimited (error: TRateLimitError) { let message = error.message; let hasMatches = rateLimitRgx.checks.some(checkRgxMix => { if (Array.isArray(checkRgxMix)) { return checkRgxMix.some(rgx => rgx.test(message)); } return checkRgxMix.test(message); }); if (hasMatches) { return true; } return false; } /** * @param rate 100/5min 30/sec */ static parseRateLimit (rate: string) { return rate.split(';').map(rate => { return ShortEpochRateLimitData.parseRateLimit(rate); }); } static extractRateLimitFromError (error: TRateLimitError): { spanMs: number, spanLimit: number, backoff?: number } { let spanMs: number; let spanLimit: number; let backoff: number; let detailsData = error.data?.data ?? error.data; if (detailsData != null) { for (let key in detailsData) { let val = detailsData[key]; if (/backoff|next|reset/i.test(key) && /^\d+$/.test(String(val))) { val = Number(val); if (val < 50_000) { // relative time if (val < 1000) { // in seconds val = val * 1000; } val = Date.now() + val; } backoff = val; continue; } let knownField = rateLimitRgx.knownFields[key]; if (knownField != null) { let data = knownField(val); backoff = data.backoff ?? backoff; spanMs = data.spanMs ?? spanMs; spanLimit = data.spanLimit ?? spanLimit; } } } let message = error.message; let fromMessage = rateLimitRgx .extracts .map(rgx => rgx.exec(message)) .filter(match => match != null)[0]; if (fromMessage) { let { limit, periodValue, periodName } = fromMessage.groups; if (periodValue != null && periodName != null) { try { spanMs = $date.parseTimespan(`${periodValue || 1}${periodName}`); } catch (error) { } spanLimit = Number(limit); } } if (spanLimit == null && spanMs == null && backoff == null) { return null; } let result = { backoff, spanLimit, spanMs, }; return result; } private awaited = { count: 0, total: 0 }; private backoff: number; private id: string; private rates: ShortEpochRateLimitData[]; constructor(opts: { id: string rates: ShortEpochRateLimitData[] }) { this.id = opts.id; this.rates = opts.rates; } status () { return { id: this.id, guards: this.rates.map(x => x.status()) }; } checkWaitTime () { return this.getWaitTimeFor(); } updateRateLimitInfo (info: ReturnType<typeof RateLimitGuard['extractRateLimitFromError']>) { if (info == null) { const SECOND = 1000; let perSecond = this.rates.find(x => x.spanMs === SECOND); if (perSecond == null) { perSecond = new ShortEpochRateLimitData(SECOND, 100); this.rates.unshift(perSecond) } info = { // retries in 2s backoff: Date.now() + 2_000, spanMs: SECOND, spanLimit: Math.floor(perSecond.spanLimit * .95) }; } if (info.backoff) { this.backoff = info.backoff; } if (info.spanLimit && info.spanMs) { l`Updating the yellow<rate limits> for ${this.id} using: bold<${info.spanLimit}> per bold<${info.spanMs}ms>`; let rate = this.rates.find(x => x.spanMs === info.spanMs); if (rate) { rate.spanLimit = info.spanLimit; } else { this.rates.push(new ShortEpochRateLimitData(info.spanMs, info.spanLimit)); } } } private getWaitTimeFor(reqCount = 1) { let waits = this.rates.map(x => x.getWaitTimeFor(reqCount)); let ms = Math.max(...waits); if (ms > 0) { //-console.log(`${Date.now()} WaitTime (${ reqCount}) ${waits.join('|')}; SpanRequests: ${ this.rates.map(x => x.ticks.length).join('|') }; Starts: ${ this.rates.map(x => x.ticks[0]).join('|') }`); } return ms; } public getSpanLimit () { return alot(this.rates).min(x => x.spanLimit); } @memd.deco.queued() public async wait(count = 1, now = Date.now()) { if (this.backoff != null) { let ms = this.backoff - Date.now(); this.backoff = null; if (ms > 0) { await $promise.wait(ms) } } let ms = this.getWaitTimeFor(count); this.rates.forEach(rate => rate.addRequests(count, now)) if (ms > 0) { this.awaited.count += 1; this.awaited.total += ms; this.onThrottle(ms); await $promise.wait(ms); } } public onComplete (tick: number) { let now = Date.now(); this.rates.forEach(rate => rate.updateRequests(tick, now)); } private onThrottle(ms: number) { let awaited = this.awaited; if (awaited.count > 0 && awaited.count % 20 === 0) { l`The Node ${this.id} with (${this.rates[0].counter}req) was ${awaited.count}x throttled, in total for ${ $date.formatTimespan(awaited.total) }. Current wait-time: ${ $date.formatTimespan(ms) }` } else if (ms > 30_000) { l`The Node ${this.id} with (${this.rates[0].counter}req) waits now for ${ $date.formatTimespan(ms) } to continue.` } //-this.rates[1].printStatus() } } /** * In short epoch rate limiter (1s, 1m, 1h) we track each request * * sliding(rolling) window | fixed window */ class ShortEpochRateLimitData { public started: number; public counter = 0; public ticks = [] as number[] private readonly TOLERANCE = 100; constructor(public spanMs: number, public spanLimit: number) { } static parseRateLimit(rate: string) { let rgx = /^(?<limit>\d+)[\\/](?<time>\w+)$/; let match = rgx.exec(rate); if (match == null) { throw new Error(`Invalid value for rate limit: ${rate}. Expects to match the regex: ${rgx.toString()}`); } let spanMs = Number(match.groups.limit); let spanLimit = $date.parseTimespan(match.groups.time); return new ShortEpochRateLimitData(spanLimit, spanMs); } status () { return { wait: this.getWaitTimeFor(1), requests: this.counter, spanStart: this.ticks[0], spanRequests: this.ticks.length, spanMs: this.spanMs, spanLimit: this.spanLimit, }; } printStatus () { let lines = alot(this.ticks) .groupBy(x => x) .map(x => `${ $date.format(new Date(x.key), 'HH:mm:ss.ms') }: ${x.values.length}`) .toArray() .join('\n'); l`Started at ${$date.format(new Date(this.started), 'dd HH:mm:ss.ms')}`; l`${ lines }` } public addRequests (amount = 1, time: number) { if (this.counter === 0) { this.started = Date.now(); } this.counter += amount; while (--amount > -1) { this.ticks.push(time); } } /** * The server has own request time, with `addTicks` we save the time BEFORE sending to the server * Here we should update the times AFTER the response received, * otherwise the Request could be removed locally from TimeWindow earlier, as it has been cleared on the backend. */ public updateRequests (tick: number, newTick: number) { for (let i = 0; i < this.ticks.length; i++) { if (this.ticks[i] === tick) { this.ticks[i] = newTick; } } } public getWaitTimeFor (ticksCount: number) { let now = Date.now(); let ticks = this.ticks; let spanStart = now - this.spanMs; for (let i = 0; i < ticks.length; i++) { if (ticks[i] < spanStart) { // older ticks, continue to slice later continue; } if (i > 0) { // remove older requests ticks.splice(0, i); } // in range tick found break; } if ((ticks.length + ticksCount) <= this.spanLimit) { // We can add requests to current span return 0; } let timeWeCanAdd = ticks[0] + this.spanMs; let ms = timeWeCanAdd - now + this.TOLERANCE; return ms; } }