UNPKG

typesense

Version:
515 lines (468 loc) 17.2 kB
import type { AxiosAdapter, AxiosRequestConfig, AxiosResponse, Method, } from "axios"; import axios from "axios"; import { Agent as HTTPAgent } from "http"; import { Agent as HTTPSAgent } from "https"; import { Logger } from "loglevel"; import Configuration, { NodeConfiguration } from "./Configuration"; import { HTTPError, ObjectAlreadyExists, ObjectNotFound, ObjectUnprocessable, RequestMalformed, RequestUnauthorized, ServerError, } from "./Errors"; import TypesenseError from "./Errors/TypesenseError"; const APIKEYHEADERNAME = "X-TYPESENSE-API-KEY"; const HEALTHY = true; const UNHEALTHY = false; interface Node extends NodeConfiguration { isHealthy: boolean; index: string | number; } const isNodeJSEnvironment = typeof process !== "undefined" && process.versions != null && process.versions.node != null; export default class ApiCall { private readonly apiKey: string; private readonly nodes: Node[]; private readonly nearestNode: Node; private readonly connectionTimeoutSeconds: number; private readonly healthcheckIntervalSeconds: number; private readonly retryIntervalSeconds: number; private readonly sendApiKeyAsQueryParam?: boolean; private readonly numRetriesPerRequest: number; private readonly additionalUserHeaders?: Record<string, string>; private readonly logger: Logger; private currentNodeIndex: number; constructor(private configuration: Configuration) { this.apiKey = this.configuration.apiKey; this.nodes = this.configuration.nodes == null ? this.configuration.nodes : JSON.parse(JSON.stringify(this.configuration.nodes)); // Make a copy, since we'll be adding additional metadata to the nodes this.nearestNode = this.configuration.nearestNode == null ? this.configuration.nearestNode : JSON.parse(JSON.stringify(this.configuration.nearestNode)); this.connectionTimeoutSeconds = this.configuration.connectionTimeoutSeconds; this.healthcheckIntervalSeconds = this.configuration.healthcheckIntervalSeconds; this.numRetriesPerRequest = this.configuration.numRetries; this.retryIntervalSeconds = this.configuration.retryIntervalSeconds; this.sendApiKeyAsQueryParam = this.configuration.sendApiKeyAsQueryParam; this.additionalUserHeaders = this.configuration.additionalHeaders; this.logger = this.configuration.logger; this.initializeMetadataForNodes(); this.currentNodeIndex = -1; } async get<T>( endpoint: string, queryParameters: any = {}, { abortSignal = null, responseType = undefined, }: { abortSignal?: any; responseType?: AxiosRequestConfig["responseType"] | undefined; } = {}, ): Promise<T> { return this.performRequest<T>("get", endpoint, { queryParameters, abortSignal, responseType, }); } async delete<T>(endpoint: string, queryParameters: any = {}): Promise<T> { return this.performRequest<T>("delete", endpoint, { queryParameters }); } async post<T>( endpoint: string, bodyParameters: any = {}, queryParameters: any = {}, additionalHeaders: any = {}, ): Promise<T> { return this.performRequest<T>("post", endpoint, { queryParameters, bodyParameters, additionalHeaders, }); } async put<T>( endpoint: string, bodyParameters: any = {}, queryParameters: any = {}, ): Promise<T> { return this.performRequest<T>("put", endpoint, { queryParameters, bodyParameters, }); } async patch<T>( endpoint: string, bodyParameters: any = {}, queryParameters: any = {}, ): Promise<T> { return this.performRequest<T>("patch", endpoint, { queryParameters, bodyParameters, }); } private getAdapter(): AxiosAdapter | undefined { if (!this.configuration.axiosAdapter) return undefined; if (typeof this.configuration.axiosAdapter === "function") return this.configuration.axiosAdapter; const isCloudflareWorkers = typeof navigator !== "undefined" && navigator.userAgent === "Cloudflare-Workers"; return isCloudflareWorkers ? axios.getAdapter(this.configuration.axiosAdapter).bind(globalThis) : axios.getAdapter(this.configuration.axiosAdapter); } async performRequest<T>( requestType: Method, endpoint: string, { queryParameters = null, bodyParameters = null, additionalHeaders = {}, abortSignal = null, responseType = undefined, skipConnectionTimeout = false, enableKeepAlive = undefined, }: { queryParameters?: any; bodyParameters?: any; additionalHeaders?: any; abortSignal?: any; responseType?: AxiosRequestConfig["responseType"] | undefined; skipConnectionTimeout?: boolean; enableKeepAlive?: boolean | undefined; }, ): Promise<T> { this.configuration.validate(); const requestNumber = Date.now(); let lastException; let wasAborted = false; this.logger.debug( `Request #${requestNumber}: Performing ${requestType.toUpperCase()} request: ${endpoint}`, ); for ( let numTries = 1; numTries <= this.numRetriesPerRequest + 1; numTries++ ) { const node = this.getNextNode(requestNumber); this.logger.debug( `Request #${requestNumber}: Attempting ${requestType.toUpperCase()} request Try #${numTries} to Node ${ node.index }`, ); if (abortSignal && abortSignal.aborted) { return Promise.reject(new Error("Request aborted by caller.")); } let abortListener: ((event: Event) => void) | undefined; try { const requestOptions: AxiosRequestConfig<string> = { adapter: this.getAdapter(), method: requestType, url: this.uriFor(endpoint, node), headers: Object.assign( {}, this.defaultHeaders(), additionalHeaders, this.additionalUserHeaders, ), maxContentLength: Infinity, maxBodyLength: Infinity, responseType, validateStatus: (status) => { /* Override default validateStatus, which only considers 2xx a success. In our case, if the server returns any HTTP code, we will handle it below. We do this to be able to raise custom errors based on response code. */ return status > 0; }, transformResponse: [ (data, headers) => { let transformedData = data; if ( headers !== undefined && typeof data === "string" && headers["content-type"] && headers["content-type"].startsWith("application/json") ) { transformedData = JSON.parse(data); } return transformedData; }, ], }; if (skipConnectionTimeout !== true) { requestOptions.timeout = this.connectionTimeoutSeconds * 1000; } if (queryParameters && Object.keys(queryParameters).length !== 0) { requestOptions.params = queryParameters; } if (this.sendApiKeyAsQueryParam) { requestOptions.params = requestOptions.params || {}; requestOptions.params["x-typesense-api-key"] = this.apiKey; } if (this.configuration.httpAgent) { this.logger.debug( `Request #${requestNumber}: Using custom httpAgent`, ); requestOptions.httpAgent = this.configuration.httpAgent; } else if (enableKeepAlive === true) { if (!isNodeJSEnvironment) { this.logger.warn( `Request #${requestNumber}: Cannot use custom httpAgent in a browser environment to enable keepAlive`, ); } else { this.logger.debug(`Request #${requestNumber}: Enabling KeepAlive`); requestOptions.httpAgent = new HTTPAgent({ keepAlive: true }); } } if (this.configuration.httpsAgent) { this.logger.debug( `Request #${requestNumber}: Using custom httpsAgent`, ); requestOptions.httpsAgent = this.configuration.httpsAgent; } else if (enableKeepAlive === true) { if (!isNodeJSEnvironment) { this.logger.warn( `Request #${requestNumber}: Cannot use custom httpAgent in a browser environment to enable keepAlive`, ); } else { this.logger.debug(`Request #${requestNumber}: Enabling keepAlive`); requestOptions.httpsAgent = new HTTPSAgent({ keepAlive: true }); } } if (this.configuration.paramsSerializer) { this.logger.debug( `Request #${requestNumber}: Using custom paramsSerializer`, ); requestOptions.paramsSerializer = this.configuration.paramsSerializer; } if ( bodyParameters && ((typeof bodyParameters === "string" && bodyParameters.length !== 0) || (typeof bodyParameters === "object" && Object.keys(bodyParameters).length !== 0)) ) { requestOptions.data = bodyParameters; } // Translate from user-provided AbortController to the Axios request cancel mechanism. if (abortSignal) { const cancelToken = axios.CancelToken; const source = cancelToken.source(); abortListener = () => { wasAborted = true; source.cancel(); }; abortSignal.addEventListener("abort", abortListener); requestOptions.cancelToken = source.token; } const response = await axios(requestOptions); if (response.status >= 1 && response.status <= 499) { // Treat any status code > 0 and < 500 to be an indication that node is healthy // We exclude 0 since some clients return 0 when request fails this.setNodeHealthcheck(node, HEALTHY); } this.logger.debug( `Request #${requestNumber}: Request to Node ${node.index} was made. Response Code was ${response.status}.`, ); if (response.status >= 200 && response.status < 300) { // If response is 2xx return a resolved promise return Promise.resolve(response.data); } else if (response.status < 500) { // Next, if response is anything but 5xx, don't retry, return a custom error return Promise.reject( this.customErrorForResponse( response, response.data?.message, requestOptions.data, ), ); } else { // Retry all other HTTP errors (HTTPStatus > 500) // This will get caught by the catch block below throw this.customErrorForResponse( response, response.data?.message, requestOptions.data, ); } } catch (error: any) { // This block handles retries for HTTPStatus > 500 and network layer issues like connection timeouts if (!wasAborted) { this.setNodeHealthcheck(node, UNHEALTHY); } lastException = error; this.logger.warn( `Request #${requestNumber}: Request to Node ${ node.index } failed due to "${error?.code ?? ""} ${error.message}${ error.response == null ? "" : " - " + JSON.stringify(error.response?.data) }"`, ); // this.logger.debug(error.stack) if (wasAborted) { return Promise.reject(new Error("Request aborted by caller.")); } if (numTries < this.numRetriesPerRequest + 1) { this.logger.warn( `Request #${requestNumber}: Sleeping for ${this.retryIntervalSeconds}s and then retrying request...`, ); } await this.timer(this.retryIntervalSeconds); } finally { if (abortSignal && abortListener) { abortSignal.removeEventListener("abort", abortListener); } } } this.logger.debug( `Request #${requestNumber}: No retries left. Raising last error`, ); return Promise.reject(lastException); } // Attempts to find the next healthy node, looping through the list of nodes once. // But if no healthy nodes are found, it will just return the next node, even if it's unhealthy // so we can try the request for good measure, in case that node has become healthy since getNextNode(requestNumber = 0): Node { // Check if nearestNode is set and is healthy, if so return it if (this.nearestNode != null) { this.logger.debug( `Request #${requestNumber}: Nodes Health: Node ${ this.nearestNode.index } is ${this.nearestNode.isHealthy === true ? "Healthy" : "Unhealthy"}`, ); if ( this.nearestNode.isHealthy === true || this.nodeDueForHealthcheck(this.nearestNode, requestNumber) ) { this.logger.debug( `Request #${requestNumber}: Updated current node to Node ${this.nearestNode.index}`, ); return this.nearestNode; } this.logger.debug( `Request #${requestNumber}: Falling back to individual nodes`, ); } // Fallback to nodes as usual this.logger.debug( `Request #${requestNumber}: Nodes Health: ${this.nodes .map( (node) => `Node ${node.index} is ${ node.isHealthy === true ? "Healthy" : "Unhealthy" }`, ) .join(" || ")}`, ); let candidateNode: Node = this.nodes[0]; for (let i = 0; i <= this.nodes.length; i++) { this.currentNodeIndex = (this.currentNodeIndex + 1) % this.nodes.length; candidateNode = this.nodes[this.currentNodeIndex]; if ( candidateNode.isHealthy === true || this.nodeDueForHealthcheck(candidateNode, requestNumber) ) { this.logger.debug( `Request #${requestNumber}: Updated current node to Node ${candidateNode.index}`, ); return candidateNode; } } // None of the nodes are marked healthy, but some of them could have become healthy since last health check. // So we will just return the next node. this.logger.debug( `Request #${requestNumber}: No healthy nodes were found. Returning the next node, Node ${candidateNode.index}`, ); return candidateNode; } nodeDueForHealthcheck(node, requestNumber = 0): boolean { const isDueForHealthcheck = Date.now() - node.lastAccessTimestamp > this.healthcheckIntervalSeconds * 1000; if (isDueForHealthcheck) { this.logger.debug( `Request #${requestNumber}: Node ${node.index} has exceeded healtcheckIntervalSeconds of ${this.healthcheckIntervalSeconds}. Adding it back into rotation.`, ); } return isDueForHealthcheck; } initializeMetadataForNodes(): void { if (this.nearestNode != null) { this.nearestNode.index = "nearestNode"; this.setNodeHealthcheck(this.nearestNode, HEALTHY); } this.nodes.forEach((node, i) => { node.index = i; this.setNodeHealthcheck(node, HEALTHY); }); } setNodeHealthcheck(node, isHealthy): void { node.isHealthy = isHealthy; node.lastAccessTimestamp = Date.now(); } uriFor(endpoint: string, node): string { if (node.url != null) { return `${node.url}${endpoint}`; } return `${node.protocol}://${node.host}:${node.port}${node.path}${endpoint}`; } defaultHeaders(): any { const defaultHeaders = {}; if (!this.sendApiKeyAsQueryParam) { defaultHeaders[APIKEYHEADERNAME] = this.apiKey; } defaultHeaders["Content-Type"] = "application/json"; return defaultHeaders; } async timer(seconds): Promise<void> { return new Promise((resolve) => setTimeout(resolve, seconds * 1000)); } customErrorForResponse( response: AxiosResponse, messageFromServer: string, httpBody?: string, ): TypesenseError { let errorMessage = `Request failed with HTTP code ${response.status}`; if ( typeof messageFromServer === "string" && messageFromServer.trim() !== "" ) { errorMessage += ` | Server said: ${messageFromServer}`; } let error = new TypesenseError(errorMessage, httpBody, response.status); if (response.status === 400) { error = new RequestMalformed(errorMessage); } else if (response.status === 401) { error = new RequestUnauthorized(errorMessage); } else if (response.status === 404) { error = new ObjectNotFound(errorMessage); } else if (response.status === 409) { error = new ObjectAlreadyExists(errorMessage); } else if (response.status === 422) { error = new ObjectUnprocessable(errorMessage); } else if (response.status >= 500 && response.status <= 599) { error = new ServerError(errorMessage); } else { error = new HTTPError(errorMessage); } return error; } }