UNPKG

connect-qos

Version:

Connect middleware that helps maintain a high quality of service during heavy traffic

251 lines (207 loc) 8.16 kB
import toobusy from 'toobusy-js'; import { Metrics, MetricsOptions, ActorStatus, BadActorType, CacheItem } from './metrics'; import { IncomingMessage, ServerResponse } from 'http'; import { Http2ServerRequest, Http2ServerResponse } from 'http2'; import { normalizeHost, resolveHostFromRequest, resolveIpFromRequest } from './util'; export type ConnectQOSMiddleware = (req: IncomingMessage|Http2ServerRequest, res: object, next: Function) => boolean; export type BeforeThrottleFn = (qos: ConnectQOS, req: IncomingMessage|Http2ServerRequest, reason: string) => boolean|undefined; export type GetMiddlewareOptions = { beforeThrottle?: BeforeThrottleFn, destroySocket?: boolean } export interface ConnectQOSOptions extends MetricsOptions { minLag?: number; maxLag?: number; errorStatusCode?: number; errorResponseDelay?: number; httpBehindProxy?: boolean; httpsBehindProxy?: boolean; } export class ConnectQOS { constructor(opts?: ConnectQOSOptions) { const { minLag = 70, maxLag = 300, errorStatusCode = 503, errorResponseDelay = 0, httpBehindProxy = false, // must be explicit to enable httpsBehindProxy = false, // must be explicit to enable ...metricOptions } = (opts || {} as ConnectQOSOptions); this.#minLag = minLag; this.#maxLag = maxLag; this.#lagRange = maxLag - minLag; this.#errorStatusCode = errorStatusCode; this.#httpBehindProxy = httpBehindProxy; this.#httpsBehindProxy = httpsBehindProxy; this.#errorResponseDelay = errorResponseDelay; // we only require `toobusy.lag` feature and can ignore toobusy() via maxLag // toobusy.maxLag(this.#maxLag); this.#metrics = new Metrics(metricOptions); this.#hostRateRange = this.#metrics.maxHostRate - this.#metrics.minHostRate; this.#ipRateRange = this.#metrics.maxIpRate - this.#metrics.minIpRate; } #minLag: number; #maxLag: number; #lagRange: number; #hostRateRange: number; #ipRateRange: number; #errorStatusCode: number; #httpBehindProxy: boolean; #httpsBehindProxy: boolean; #errorResponseDelay: number; #metrics: Metrics; get minLag(): number { return this.#minLag; } get maxLag(): number { return this.#maxLag; } get errorStatusCode(): number { return this.#errorStatusCode; } get httpBehindProxy(): boolean { return this.#httpBehindProxy; } get httpsBehindProxy(): boolean { return this.#httpsBehindProxy; } get metrics(): Metrics { return this.#metrics; } getMiddleware({ beforeThrottle, destroySocket = true }: GetMiddlewareOptions = {}) { const self = this; function sendError(res: Http2ServerResponse | ServerResponse) { res.statusCode = self.#errorStatusCode; res.end(); if (destroySocket) { if (res.stream) { // H2 res.stream.session?.destroy(); } else if (res.socket?.destroyed === false) { res.socket.destroySoon(); } } } return function QOSMiddleware(req, res, next) { const reason = self.shouldThrottleRequest(req); if (reason) { if (!beforeThrottle || beforeThrottle(self, req, reason as string) !== false) { // if no throttle handler OR the throttle handler does not explicitly reject, do it return void self.#errorResponseDelay ? setTimeout(sendError, self.#errorResponseDelay, res).unref() : sendError(res); } } // continue next(); }; } shouldThrottleRequest(req: IncomingMessage|Http2ServerRequest): BadActorType|boolean { const host = this.resolveHost(req); const hostStatus = this.getHostStatus(host, false); // defer tracking const ipStatus = this.getIpStatus(req, false); // defer tracking // never throttle whitelisted actors if (hostStatus === ActorStatus.Whitelisted || ipStatus === ActorStatus.Whitelisted) return false; if (hostStatus === ActorStatus.Bad) return BadActorType.badHost; if (ipStatus === ActorStatus.Bad) return BadActorType.badIp; // If host is exceeding host ratio and IP rate override is either not set or exceeded, return hostViolation status const maxIpRateHostViolation = this.#metrics.maxIpRateHostViolation; if ( this.metrics.hostRatioViolations.has(host) && (!maxIpRateHostViolation || this.getIpStatus(req, false, Math.max(0, maxIpRateHostViolation - this.#metrics.minIpRate)) === ActorStatus.Bad) ) { return BadActorType.hostViolation; } // only track if NOT throttling this.trackRequest(req); // do not throttle user return false; } get lag(): number { return toobusy.lag(); } get lagRatio(): number { // lagRatio = 0-1 const lag = toobusy.lag(); // if lag exceeds maxLag will cap ratio at 1 return lag > this.#minLag ? Math.min(1, (lag - this.#minLag) / this.#lagRange) : 0; } resolveHost(source: string|IncomingMessage|Http2ServerRequest): string { return typeof source === 'string' ? normalizeHost(source) : resolveHostFromRequest(source) ; } getHostStatus(source: string|IncomingMessage|Http2ServerRequest, track: boolean = true): ActorStatus { const sourceStr: string = this.resolveHost(source); const sourceInfo = this.#metrics.getHostInfo(sourceStr); const status = getStatus(sourceInfo, { minRate: this.#metrics.minHostRate, rateRange: this.#hostRateRange, lagRatio: this.lagRatio }); if (sourceInfo === ActorStatus.Whitelisted) return ActorStatus.Whitelisted; if (track && status === ActorStatus.Good) { // only track if we're NOT throttling // forward cache to avoid additional lookup this.#metrics.trackHost(sourceStr, sourceInfo as CacheItem); } return status; } isBadHost(host: string|IncomingMessage|Http2ServerRequest, track: boolean = true): boolean { return this.getHostStatus(host, track) === ActorStatus.Bad; } resolveIp(source: string|IncomingMessage|Http2ServerRequest): string { return typeof source === 'string' ? source : resolveIpFromRequest(source, (source as Http2ServerRequest).scheme === 'https' ? this.#httpsBehindProxy : this.#httpBehindProxy) ; } getIpStatus(source: string|IncomingMessage|Http2ServerRequest, track: boolean = true, rateRange: number = this.#ipRateRange): ActorStatus { const sourceStr: string = this.resolveIp(source); const sourceInfo = this.#metrics.getIpInfo(sourceStr); const status = getStatus(sourceInfo, { minRate: this.#metrics.minIpRate, lagRatio: this.lagRatio, rateRange }); if (track && status === ActorStatus.Good) { // only track if we're NOT throttling // forward cache to avoid additional lookup this.#metrics.trackIp(sourceStr, sourceInfo as CacheItem); } return status; } isBadIp(ip: string|IncomingMessage|Http2ServerRequest, track: boolean = true): boolean { return this.getIpStatus(ip, track) === ActorStatus.Bad; } trackRequest(req: IncomingMessage|Http2ServerRequest): void { const host = this.resolveHost(req); this.#metrics.trackHost(host); const ip = this.resolveIp(req); this.#metrics.trackIp(ip); } } export type GetStatusOptions = { minRate: number, rateRange: number, lagRatio: number } function getStatus(sourceInfo: ActorStatus|CacheItem|undefined, { minRate, rateRange, lagRatio }: GetStatusOptions): ActorStatus { if (sourceInfo === ActorStatus.Whitelisted) return ActorStatus.Whitelisted; // if no history OR rate limiting disabled assume it's good if (!sourceInfo || !minRate) return ActorStatus.Good; // lagRatio = 0-1 (min-max) // minRate = 10 // maxRate = 30 // rateRange = (maxRate - minRate) = 20 // ((1-0.00) * 20) + 10 = 30 // minLag RPS // ((1-0.25) * 20) + 10 = 25 // ((1-0.50) * 20) + 10 = 20 // ((1-0.75) * 20) + 10 = 15 // ((1-1.00) * 20) + 10 = 10 // maxLag RPS const dynamicRate = ((1-lagRatio) * rateRange) + minRate; // min/max not required since lagRatio is guaranteed // to be 0-1 regardless of (under|over)flow return sourceInfo.rate > dynamicRate ? ActorStatus.Bad : ActorStatus.Good; }