UNPKG

@lodestar/beacon-node

Version:

A Typescript implementation of the beacon chain

219 lines (189 loc) • 7.92 kB
import bearerAuthPlugin from "@fastify/bearer-auth"; import {fastifyCors} from "@fastify/cors"; import {FastifyError, FastifyInstance, FastifyRequest, errorCodes, fastify} from "fastify"; import {parse as parseQueryString} from "qs"; import {addSszContentTypeParser} from "@lodestar/api/server"; import {ErrorAborted, Gauge, Histogram, Logger} from "@lodestar/utils"; import {isLocalhostIP} from "../../util/ip.js"; import {ApiError, FailureList, IndexedError, NodeIsSyncing} from "../impl/errors.js"; import {HttpActiveSocketsTracker, SocketMetrics} from "./activeSockets.js"; export type RestApiServerOpts = { port: number; cors?: string; address?: string; bearerToken?: string; headerLimit?: number; bodyLimit?: number; stacktraces?: boolean; swaggerUI?: boolean; }; export type RestApiServerModules = { logger: Logger; metrics: RestApiServerMetrics | null; }; export type RestApiServerMetrics = SocketMetrics & { requests: Gauge<{operationId: string}>; responseTime: Histogram<{operationId: string}>; errors: Gauge<{operationId: string}>; }; /** * Error response body format as defined in beacon-api spec * * See https://github.com/ethereum/beacon-APIs/blob/v3.1.0/types/http.yaml */ type ErrorResponse = { code: number; message: string; stacktraces?: string[]; }; type IndexedErrorResponse = ErrorResponse & { failures?: FailureList; }; /** * Error code used by Fastify if media type is not supported (415) */ const INVALID_MEDIA_TYPE_CODE = errorCodes.FST_ERR_CTP_INVALID_MEDIA_TYPE().code; /** * Error code used by Fastify if JSON schema validation failed */ const SCHEMA_VALIDATION_ERROR_CODE = errorCodes.FST_ERR_VALIDATION().code; /** * REST API powered by `fastify` server. */ export class RestApiServer { protected readonly server: FastifyInstance; protected readonly logger: Logger; private readonly activeSockets: HttpActiveSocketsTracker; constructor( protected readonly opts: RestApiServerOpts, modules: RestApiServerModules ) { // Apply opts defaults const {logger, metrics} = modules; const server = fastify({ logger: false, ajv: {customOptions: {coerceTypes: "array"}}, routerOptions: { querystringParser: (str) => parseQueryString(str, { // Array as comma-separated values must be supported to be OpenAPI spec compliant comma: true, // Drop support for array query strings like `id[0]=1&id[1]=2&id[2]=3` as those are not required to // be OpenAPI spec compliant and results are inconsistent, see https://github.com/ljharb/qs/issues/331. // The schema validation will catch this and throw an error as parsed query string results in an object. parseArrays: false, }), }, bodyLimit: opts.bodyLimit, http: {maxHeaderSize: opts.headerLimit}, }); addSszContentTypeParser(server); this.activeSockets = new HttpActiveSocketsTracker(server.server, metrics); // To parse our ApiError -> statusCode server.setErrorHandler<FastifyError | Error>((err, _req, res) => { const stacktraces = opts.stacktraces ? err.stack?.split("\n") : undefined; if ("validation" in err && err.validation) { const {instancePath = "unknown", message} = err.validation?.[0] ?? {}; const payload: ErrorResponse = { code: 400, message: `${instancePath.substring(instancePath.lastIndexOf("/") + 1)} ${message}`, stacktraces, }; void res.status(400).send(payload); } else if (err instanceof IndexedError) { const payload: IndexedErrorResponse = { code: err.statusCode, message: err.message, failures: err.failures, stacktraces, }; void res.status(err.statusCode).send(payload); } else { // Convert our custom ApiError into status code const statusCode = err instanceof ApiError ? err.statusCode : 500; const payload: ErrorResponse = {code: statusCode, message: err.message, stacktraces}; void res.status(statusCode).send(payload); } }); server.setNotFoundHandler((req, res) => { const message = `Route ${req.raw.method}:${req.raw.url} not found`; this.logger.warn(message); const payload: ErrorResponse = {code: 404, message}; void res.code(404).send(payload); }); if (opts.cors) { void server.register(fastifyCors, {origin: opts.cors}); } if (opts.bearerToken) { void server.register(bearerAuthPlugin, {keys: new Set([opts.bearerToken])}); } // Log all incoming request to debug (before parsing). TODO: Should we hook latter in the lifecycle? https://www.fastify.io/docs/latest/Lifecycle/ // Note: Must be an async method so fastify can continue the release lifecycle. Otherwise we must call done() or the request stalls server.addHook("onRequest", async (req, _res) => { const operationId = getOperationId(req); this.logger.debug(`Req ${req.id} ${req.ip} ${operationId}`); metrics?.requests.inc({operationId}); }); server.addHook("preHandler", async (req, _res) => { const operationId = getOperationId(req); this.logger.debug(`Exec ${req.id} ${req.ip} ${operationId}`); }); // Log after response server.addHook("onResponse", async (req, res) => { const operationId = getOperationId(req); this.logger.debug(`Res ${req.id} ${operationId} - ${res.raw.statusCode}`); metrics?.responseTime.observe({operationId}, res.elapsedTime / 1000); }); server.addHook("onError", async (req, _res, err) => { // Don't log ErrorAborted errors, they happen on node shutdown and are not useful // Don't log NodeISSyncing errors, they happen very frequently while syncing and the validator polls duties if (err instanceof ErrorAborted || err instanceof NodeIsSyncing) return; const operationId = getOperationId(req); if (err instanceof ApiError || [INVALID_MEDIA_TYPE_CODE, SCHEMA_VALIDATION_ERROR_CODE].includes(err.code)) { this.logger.warn(`Req ${req.id} ${operationId} failed`, {reason: err.message}); } else { this.logger.error(`Req ${req.id} ${operationId} error`, {}, err); } metrics?.errors.inc({operationId}); }); this.server = server; this.logger = logger; } /** * Start the REST API server. */ async listen(): Promise<void> { try { const host = this.opts.address; await this.server.listen({port: this.opts.port, host}); const {address, port} = this.server.addresses()[0]; this.logger.info("Started REST API server", {address: `http://${address}:${port}`}); if (!host || !isLocalhostIP(host)) { this.logger.warn("REST API server is exposed, ensure untrusted traffic cannot reach this API"); } } catch (e) { this.logger.error("Error starting REST api server", this.opts, e as Error); throw e; } } /** * Close the server instance and terminate all existing connections. */ async close(): Promise<void> { // In NodeJS land calling close() only causes new connections to be rejected. // Existing connections can prevent .close() from resolving for potentially forever. // In Lodestar case when the BeaconNode wants to close we will attempt to gracefully // close all existing connections but forcefully terminate after timeout for a fast shutdown. // Inspired by https://github.com/gajus/http-terminator/ await this.activeSockets.terminate(); await this.server.close(); this.logger.debug("REST API server closed"); } /** For child classes to override */ protected shouldIgnoreError(_err: Error): boolean { return false; } } function getOperationId(req: FastifyRequest): string { return req.routeOptions.schema?.operationId ?? "unknown"; }