Improvements
This commit is contained in:
Executable
+89
@@ -0,0 +1,89 @@
|
||||
import type { RequestHandler } from 'express';
|
||||
|
||||
export class RpcError extends Error {
|
||||
readonly status: number;
|
||||
constructor(status: number, message: string) { super(message); this.status = status; }
|
||||
}
|
||||
export type ResourceConfig = { requestsPerSecond: number; writesPerSecond: number; active: number;
|
||||
responseBytes: number; bytesPerSecond: number; legacyBlocks: number };
|
||||
export function resourceConfig(env: NodeJS.ProcessEnv = process.env): ResourceConfig {
|
||||
const number = (name: string, fallback: number, min: number, max: number) => {
|
||||
const text = env[name] ?? String(fallback), n = Number(text);
|
||||
if (!/^\d+$/.test(text) || !Number.isSafeInteger(n) || n < min || n > max) throw new Error(`Invalid ${name}`);
|
||||
return n;
|
||||
};
|
||||
return { requestsPerSecond: number('WIKIDECK_RPC_REQUESTS_PER_SECOND', 120, 1, 1000),
|
||||
writesPerSecond: number('WIKIDECK_RPC_WRITES_PER_SECOND', 10, 1, 100),
|
||||
active: number('WIKIDECK_RPC_MAX_ACTIVE', 32, 1, 256),
|
||||
responseBytes: number('WIKIDECK_RPC_RESPONSE_BYTES', 2 * 1024 * 1024, 128 * 1024, 2 * 1024 * 1024),
|
||||
bytesPerSecond: number('WIKIDECK_RPC_BYTES_PER_SECOND', 8 * 1024 * 1024, 2 * 1024 * 1024, 128 * 1024 * 1024),
|
||||
legacyBlocks: number('WIKIDECK_RPC_LEGACY_BLOCKS', 128, 0, 128) };
|
||||
}
|
||||
|
||||
/** Bounded, constant-storage global budgets. No per-client map or retry queue.
|
||||
* Peer traffic has its own lane so a saturated browser lane cannot consume its tokens. */
|
||||
export class RpcBudget {
|
||||
private tokens: number;
|
||||
private writeTokens: number;
|
||||
private byteTokens: number;
|
||||
private updated: number;
|
||||
private active = 0;
|
||||
private rejected = 0;
|
||||
readonly config: ResourceConfig;
|
||||
constructor(config: ResourceConfig, privateClock: () => number = () => performance.now()) {
|
||||
this.config = config;
|
||||
this.clock = privateClock;
|
||||
this.tokens = config.requestsPerSecond; this.writeTokens = config.writesPerSecond;
|
||||
this.byteTokens = config.bytesPerSecond; this.updated = this.clock();
|
||||
}
|
||||
private readonly clock: () => number;
|
||||
private refill() {
|
||||
const now = this.clock(), elapsed = Math.max(0, now - this.updated) / 1000;
|
||||
this.updated = now;
|
||||
this.tokens = Math.min(this.config.requestsPerSecond, this.tokens + elapsed * this.config.requestsPerSecond);
|
||||
this.writeTokens = Math.min(this.config.writesPerSecond, this.writeTokens + elapsed * this.config.writesPerSecond);
|
||||
this.byteTokens = Math.min(this.config.bytesPerSecond, this.byteTokens + elapsed * this.config.bytesPerSecond);
|
||||
}
|
||||
acquire(write: boolean): () => void {
|
||||
this.refill();
|
||||
if (this.active >= this.config.active || this.tokens < 1 || (write && this.writeTokens < 1)) {
|
||||
this.rejected++; throw new RpcError(503, 'Node RPC admission budget exhausted; retry later.');
|
||||
}
|
||||
this.tokens--; if (write) this.writeTokens--;
|
||||
this.active++;
|
||||
let released = false;
|
||||
return () => { if (!released) { this.active--; released = true; } };
|
||||
}
|
||||
encode(value: unknown): string {
|
||||
const encoded = JSON.stringify(value), bytes = Buffer.byteLength(encoded);
|
||||
if (bytes > this.config.responseBytes) {
|
||||
this.rejected++; throw new RpcError(413, 'RPC response limit exceeded; use smaller paged requests.');
|
||||
}
|
||||
this.refill();
|
||||
if (bytes > this.byteTokens) { this.rejected++; throw new RpcError(503, 'Node RPC response-byte budget exhausted; retry later.'); }
|
||||
this.byteTokens -= bytes;
|
||||
return encoded;
|
||||
}
|
||||
status() { return { active: this.active, rejected: this.rejected }; }
|
||||
middleware(): RequestHandler {
|
||||
return (req, res, next) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
let release: () => void;
|
||||
try { release = this.acquire(req.method !== 'GET' && req.method !== 'HEAD'); }
|
||||
catch (error) {
|
||||
res.setHeader('Retry-After', '1');
|
||||
res.status(503).json({ error: (error as Error).message }); return;
|
||||
}
|
||||
res.once('finish', release); res.once('close', release);
|
||||
// Express.json normally serializes before send; intercept here so no oversized payload is sent.
|
||||
const originalJson = res.json.bind(res);
|
||||
res.json = (value: unknown) => {
|
||||
try {
|
||||
const encoded = this.encode(value);
|
||||
return res.type('application/json').send(encoded);
|
||||
} catch (error) { res.json = originalJson; throw error; }
|
||||
};
|
||||
next();
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user