Files
oneuptime/Common/Utils/API.ts
2026-03-18 13:13:33 +00:00

933 lines
28 KiB
TypeScript

import AnalyticsBaseModel from "../Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel";
import BaseModel from "../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
import HTTPErrorResponse from "../Types/API/HTTPErrorResponse";
import HTTPMethod from "../Types/API/HTTPMethod";
import HTTPResponse from "../Types/API/HTTPResponse";
import Headers from "../Types/API/Headers";
import Hostname from "../Types/API/Hostname";
import Protocol from "../Types/API/Protocol";
import Route from "../Types/API/Route";
import URL from "../Types/API/URL";
import Dictionary from "../Types/Dictionary";
import APIException from "../Types/Exception/ApiException";
import { JSONArray, JSONObject } from "../Types/JSON";
import RequestFailedDetails, {
RequestFailedPhase,
} from "../Types/Probe/RequestFailedDetails";
import axios, {
AxiosError,
AxiosProgressEvent,
AxiosRequestConfig,
AxiosResponse,
} from "axios";
import Sleep from "../Types/Sleep";
import type { Agent as HttpAgent } from "http";
import type { Agent as HttpsAgent } from "https";
export interface RequestOptions {
retries?: number | undefined;
exponentialBackoff?: boolean | undefined;
timeout?: number | undefined;
doNotFollowRedirects?: boolean | undefined;
// Per-request proxy agent support (Probe supplies these instead of mutating global axios defaults)
httpAgent?: HttpAgent | undefined;
httpsAgent?: HttpsAgent | undefined;
skipAuthRefresh?: boolean | undefined;
hasAttemptedAuthRefresh?: boolean | undefined;
onUploadProgress?: ((event: AxiosProgressEvent) => void) | undefined;
}
export interface APIRequestOptions {
url: URL;
data?: JSONObject | JSONArray;
headers?: Headers;
params?: Dictionary<string>;
options?: RequestOptions;
}
export interface APIFetchOptions {
method: HTTPMethod;
url: URL;
data?: JSONObject | JSONArray;
headers?: Headers;
params?: Dictionary<string>;
options?: RequestOptions;
}
export interface AuthRetryContext {
error: HTTPErrorResponse;
request: {
method: HTTPMethod;
url: URL;
data?: JSONObject | JSONArray;
headers?: Headers;
params?: Dictionary<string>;
options?: RequestOptions;
};
}
export default class API {
private _protocol: Protocol = Protocol.HTTPS;
public get protocol(): Protocol {
return this._protocol;
}
public set protocol(v: Protocol) {
this._protocol = v;
}
private _hostname!: Hostname;
public get hostname(): Hostname {
return this._hostname;
}
public set hostname(v: Hostname) {
this._hostname = v;
}
private _baseRoute!: Route;
public get baseRoute(): Route {
return this._baseRoute;
}
public set baseRoute(v: Route) {
this._baseRoute = v;
}
public constructor(
protocol: Protocol,
hostname: Hostname,
baseRoute?: Route,
) {
this.protocol = protocol;
this.hostname = hostname;
if (baseRoute) {
this.baseRoute = baseRoute;
} else {
this.baseRoute = new Route("/");
}
}
protected static async tryRefreshAuth(
_context: AuthRetryContext,
): Promise<boolean> {
return false;
}
public async get<
T extends JSONObject | JSONArray | BaseModel | Array<BaseModel>,
>(options: APIRequestOptions): Promise<HTTPResponse<T> | HTTPErrorResponse> {
return await API.get<T>(options);
}
public async delete<
T extends JSONObject | JSONArray | BaseModel | Array<BaseModel>,
>(options: APIRequestOptions): Promise<HTTPResponse<T> | HTTPErrorResponse> {
return await API.delete<T>(options);
}
public async head<
T extends JSONObject | JSONArray | BaseModel | Array<BaseModel>,
>(options: APIRequestOptions): Promise<HTTPResponse<T> | HTTPErrorResponse> {
return await API.head<T>(options);
}
public async put<
T extends JSONObject | JSONArray | BaseModel | Array<BaseModel>,
>(options: APIRequestOptions): Promise<HTTPResponse<T> | HTTPErrorResponse> {
return await API.put<T>(options);
}
public async patch<
T extends JSONObject | JSONArray | BaseModel | Array<BaseModel>,
>(options: APIRequestOptions): Promise<HTTPResponse<T> | HTTPErrorResponse> {
return await API.patch<T>(options);
}
public static handleError(
error: HTTPErrorResponse | APIException,
): HTTPErrorResponse | APIException {
return error;
}
protected static async onResponseSuccessHeaders(
headers: Dictionary<string>,
): Promise<Dictionary<string>> {
return Promise.resolve(headers);
}
public static getDefaultHeaders(_props?: any): Headers {
const defaultHeaders: Headers = {
"Access-Control-Allow-Origin": "*",
Accept: "application/json",
"Content-Type": "application/json;charset=UTF-8",
};
return defaultHeaders;
}
protected static getHeaders(headers?: Headers): Headers {
let defaultHeaders: Headers = this.getDefaultHeaders();
if (headers) {
defaultHeaders = {
...defaultHeaders,
...headers,
};
}
return defaultHeaders;
}
public static async get<
T extends
| JSONObject
| JSONArray
| BaseModel
| Array<BaseModel>
| AnalyticsBaseModel
| Array<AnalyticsBaseModel>,
>(options: APIRequestOptions): Promise<HTTPResponse<T> | HTTPErrorResponse> {
const { url, data, headers, params, options: newOptions } = options;
if (!url) {
throw new APIException("URL is required for static method");
}
const fetchOptions: APIFetchOptions = {
method: HTTPMethod.GET,
url,
...(data && { data }),
...(headers && { headers }),
...(params && { params }),
...(newOptions && { options: newOptions }),
};
return await this.fetch<T>(fetchOptions);
}
public static async delete<
T extends
| JSONObject
| JSONArray
| BaseModel
| Array<BaseModel>
| AnalyticsBaseModel
| Array<AnalyticsBaseModel>,
>(options: APIRequestOptions): Promise<HTTPResponse<T> | HTTPErrorResponse> {
const { url, data, headers, params, options: newOptions } = options;
if (!url) {
throw new APIException("URL is required for static method");
}
const fetchOptions: APIFetchOptions = {
method: HTTPMethod.DELETE,
url,
...(data && { data }),
...(headers && { headers }),
...(params && { params }),
...(newOptions && { options: newOptions }),
};
return await this.fetch<T>(fetchOptions);
}
public static async head<
T extends
| JSONObject
| JSONArray
| BaseModel
| Array<BaseModel>
| AnalyticsBaseModel
| Array<AnalyticsBaseModel>,
>(options: APIRequestOptions): Promise<HTTPResponse<T> | HTTPErrorResponse> {
const { url, data, headers, params, options: newOptions } = options;
if (!url) {
throw new APIException("URL is required for static method");
}
const fetchOptions: APIFetchOptions = {
method: HTTPMethod.HEAD,
url,
...(data && { data }),
...(headers && { headers }),
...(params && { params }),
...(newOptions && { options: newOptions }),
};
return await this.fetch<T>(fetchOptions);
}
public static async put<
T extends
| JSONObject
| JSONArray
| BaseModel
| Array<BaseModel>
| AnalyticsBaseModel
| Array<AnalyticsBaseModel>,
>(options: APIRequestOptions): Promise<HTTPResponse<T> | HTTPErrorResponse> {
const { url, data, headers, params, options: newOptions } = options;
if (!url) {
throw new APIException("URL is required for static method");
}
const fetchOptions: APIFetchOptions = {
method: HTTPMethod.PUT,
url,
...(data && { data }),
...(headers && { headers }),
...(params && { params }),
...(newOptions && { options: newOptions }),
};
return await this.fetch<T>(fetchOptions);
}
public static async patch<
T extends
| JSONObject
| JSONArray
| BaseModel
| Array<BaseModel>
| AnalyticsBaseModel
| Array<AnalyticsBaseModel>,
>(options: APIRequestOptions): Promise<HTTPResponse<T> | HTTPErrorResponse> {
const { url, data, headers, params, options: newOptions } = options;
if (!url) {
throw new APIException("URL is required for static method");
}
const fetchOptions: APIFetchOptions = {
method: HTTPMethod.PATCH,
url,
...(data && { data }),
...(headers && { headers }),
...(params && { params }),
...(newOptions && { options: newOptions }),
};
return await this.fetch<T>(fetchOptions);
}
public static async post<
T extends
| JSONObject
| JSONArray
| BaseModel
| Array<BaseModel>
| AnalyticsBaseModel
| Array<AnalyticsBaseModel>,
>(options: APIRequestOptions): Promise<HTTPResponse<T> | HTTPErrorResponse> {
const { url, data, headers, params, options: newOptions } = options;
if (!url) {
throw new APIException("URL is required for static method");
}
const fetchOptions: APIFetchOptions = {
method: HTTPMethod.POST,
url,
...(data && { data }),
...(headers && { headers }),
...(params && { params }),
...(newOptions && { options: newOptions }),
};
return await this.fetch<T>(fetchOptions);
}
public static async fetch<
T extends
| JSONObject
| JSONArray
| BaseModel
| Array<BaseModel>
| AnalyticsBaseModel
| Array<AnalyticsBaseModel>,
>(options: APIFetchOptions): Promise<HTTPResponse<T> | HTTPErrorResponse> {
const { method, url, data, headers, params, options: newOptions } = options;
if (!url) {
throw new APIException("URL is required for static method");
}
return await this.fetchInternal<T>(
method,
url,
data,
headers,
params,
newOptions,
);
}
private static async fetchInternal<
T extends
| JSONObject
| JSONArray
| BaseModel
| Array<BaseModel>
| AnalyticsBaseModel
| Array<AnalyticsBaseModel>,
>(
method: HTTPMethod,
url: URL,
data?: JSONObject | JSONArray,
headers?: Headers,
params?: Dictionary<string>,
options?: RequestOptions,
): Promise<HTTPResponse<T> | HTTPErrorResponse> {
const apiHeaders: Headers = this.getHeaders(headers);
if (params) {
url.addQueryParams(params);
}
try {
const finalHeaders: Dictionary<string> = {
...apiHeaders,
...headers,
};
let finalBody: JSONObject | JSONArray | URLSearchParams | undefined =
data;
// if content-type is form-url-encoded, then stringify the data
if (
finalHeaders["Content-Type"] === "application/x-www-form-urlencoded" &&
data
) {
finalBody = new URLSearchParams(data as Dictionary<string>);
}
let currentRetry: number = 0;
const maxRetries: number = options?.retries || 0;
const exponentialBackoff: boolean = options?.exponentialBackoff || false;
let result: AxiosResponse | null = null;
while (currentRetry <= maxRetries) {
currentRetry++;
try {
const axiosOptions: AxiosRequestConfig = {
method: method,
url: url.toString(),
headers: finalHeaders,
data: finalBody,
};
if (options?.timeout) {
axiosOptions.timeout = options.timeout;
}
if (options?.doNotFollowRedirects) {
axiosOptions.maxRedirects = 0;
}
// Attach proxy agents per request if provided (avoids global side-effects)
if (options?.httpAgent) {
(axiosOptions as AxiosRequestConfig).httpAgent = options.httpAgent;
}
if (options?.httpsAgent) {
(axiosOptions as AxiosRequestConfig).httpsAgent =
options.httpsAgent;
}
if (options?.onUploadProgress) {
axiosOptions.onUploadProgress = options.onUploadProgress;
}
result = await axios(axiosOptions);
break;
} catch (e) {
if (currentRetry <= maxRetries) {
if (exponentialBackoff) {
await Sleep.sleep(2 ** currentRetry * 1000);
}
continue;
} else {
throw e;
}
}
}
if (!result) {
throw new APIException("No response received from server.");
}
result.headers = await this.onResponseSuccessHeaders(
result.headers as Dictionary<string>,
);
const response: HTTPResponse<T> = new HTTPResponse<T>(
result.status,
result.data,
result.headers as Dictionary<string>,
);
return response;
} catch (e) {
const error: Error | AxiosError = e as Error | AxiosError;
if (!axios.isAxiosError(error)) {
throw new APIException(error.message);
}
const errorResponse: HTTPErrorResponse = this.getErrorResponse(error);
if (
error.response?.status === 401 &&
!options?.skipAuthRefresh &&
!options?.hasAttemptedAuthRefresh
) {
const retryUrl: URL = URL.fromString(url.toString());
const requestContext: AuthRetryContext["request"] = {
method,
url: retryUrl,
};
if (data) {
requestContext.data = data;
}
if (headers) {
requestContext.headers = headers;
}
if (params) {
requestContext.params = params;
}
if (options) {
requestContext.options = options;
}
const refreshed: boolean = await this.tryRefreshAuth({
error: errorResponse,
request: requestContext,
});
if (refreshed) {
const nextOptions: RequestOptions = {
...(options || {}),
hasAttemptedAuthRefresh: true,
};
return await this.fetchInternal(
method,
retryUrl,
data,
headers,
params,
nextOptions,
);
}
}
this.handleError(errorResponse);
return errorResponse;
}
}
private static getErrorResponse(error: AxiosError): HTTPErrorResponse {
if (error.response) {
return new HTTPErrorResponse(
error.response.status,
error.response.data as JSONObject | JSONArray,
error.response.headers as Dictionary<string>,
);
}
// get url from error
const url: string = error?.config?.url || "";
// Get a meaningful error message, avoiding generic "Error" strings
let errorMessage: string = error.message || "";
// If error message is empty or just "Error", try to get more details from the error
if (
!errorMessage ||
errorMessage.toLowerCase() === "error" ||
errorMessage.trim() === ""
) {
// Check for common axios error codes
if (error.code) {
switch (error.code) {
case "ECONNREFUSED":
errorMessage = "Connection refused";
break;
case "ECONNRESET":
errorMessage = "Connection reset";
break;
case "ETIMEDOUT":
errorMessage = "Connection timed out";
break;
case "ENOTFOUND":
errorMessage = "Host not found";
break;
case "ECONNABORTED":
errorMessage = "Connection aborted";
break;
case "ERR_NETWORK":
errorMessage = "Network error";
break;
case "ERR_BAD_REQUEST":
errorMessage = "Bad request";
break;
case "ERR_BAD_RESPONSE":
errorMessage = "Bad response from server";
break;
default:
errorMessage = error.code || "Unknown error";
}
} else {
errorMessage = "Request failed";
}
}
throw new APIException(`Request failed to ${url}. ${errorMessage}`, error);
}
public static getFriendlyErrorMessage(error: AxiosError | Error): string {
let errorString: string = error.message || error.toString();
if (error instanceof APIException) {
// Get the nested error message, but avoid duplicating or adding empty/generic messages
let nestedErrorMessage: string = "";
if (error.error) {
// Get the error message, avoiding generic "Error" or empty strings
const errMsg: string = error.error.message || "";
const errStr: string = error.error.toString() || "";
// Check if the error message is meaningful (not just "Error" or empty)
if (
errMsg &&
errMsg.trim().toLowerCase() !== "error" &&
errMsg.trim() !== ""
) {
nestedErrorMessage = errMsg;
} else if (
errStr &&
errStr.trim().toLowerCase() !== "error" &&
errStr.trim().toLowerCase() !== "error:" &&
errStr.trim() !== "" &&
!errStr.toLowerCase().startsWith("error:")
) {
nestedErrorMessage = errStr;
}
}
// Only append nested error if it's meaningful and not already in the main message
if (nestedErrorMessage && !error.message?.includes(nestedErrorMessage)) {
errorString = `${error.message?.toString()} ${nestedErrorMessage}`;
} else {
errorString = error.message?.toString() || "";
}
}
// Handle AggregateError by extracting the underlying error messages
if (
error &&
(error as any).name === "AggregateError" &&
(error as any).errors
) {
const aggregateErrors: Error[] = (error as any).errors as Error[];
const errorMessages: string[] = aggregateErrors
.map((err: Error) => {
return err.message || err.toString();
})
.filter((msg: string) => {
return msg && msg.trim().length > 0;
});
if (errorMessages.length > 0) {
errorString = errorMessages.join("; ");
}
}
const status: number | undefined = (error as AxiosError)?.response?.status;
const lowerErr: string = errorString.toLocaleLowerCase();
if (status !== 400 && lowerErr.includes("network error")) {
return "Network Error.";
}
if (status !== 400 && lowerErr.includes("timeout")) {
return "Timeout Error.";
}
if (status !== 400 && lowerErr.includes("request aborted")) {
return "Request Aborted.";
}
if (status !== 400 && lowerErr.includes("canceled")) {
return "Request Canceled.";
}
if (status !== 400 && lowerErr.includes("connection refused")) {
return "Connection Refused.";
}
if (status !== 400 && lowerErr.includes("connection reset")) {
return "Connection Reset.";
}
if (status !== 400 && lowerErr.includes("connection closed")) {
return "Connection Closed.";
}
if (status !== 400 && lowerErr.includes("connection failed")) {
return "Connection Failed.";
}
if (status !== 400 && lowerErr.includes("enotfound")) {
return "Cannot Find Host.";
}
if (status !== 400 && lowerErr.includes("econnreset")) {
return "Connection Reset.";
}
if (status !== 400 && lowerErr.includes("econnrefused")) {
return "Connection Refused.";
}
if (status !== 400 && lowerErr.includes("econnaborted")) {
return "Connection Aborted.";
}
if (status !== 400 && lowerErr.includes("certificate has expired")) {
return "SSL Certificate Expired.";
}
if (
errorString
.toLocaleLowerCase()
.includes("certificate signed by unknown authority")
) {
return "SSL Certificate Signed By Unknown Authority.";
}
if (errorString.toLocaleLowerCase().includes("self-signed certificate")) {
return "Self Signed Certificate.";
}
return errorString;
}
/**
* Extracts detailed error information from an axios error or generic error.
* This provides more context about where and why a request failed.
*/
public static getRequestFailedDetails(
error: AxiosError | Error | unknown,
): RequestFailedDetails {
const axiosError: AxiosError | null = axios.isAxiosError(error)
? (error as AxiosError)
: null;
const errorCode: string | undefined = axiosError?.code;
const rawErrorMessage: string =
(error as Error)?.message || String(error) || "Unknown error";
// Helper to determine the phase and description based on error code/message
const lowerMessage: string = rawErrorMessage.toLowerCase();
// DNS resolution failures
if (errorCode === "ENOTFOUND" || lowerMessage.includes("enotfound")) {
return {
failedPhase: RequestFailedPhase.DNSResolution,
errorCode: errorCode || "ENOTFOUND",
errorDescription:
"DNS resolution failed. The hostname could not be resolved to an IP address. Please verify the hostname is correct and that DNS is working properly.",
rawErrorMessage,
};
}
// Connection refused
if (errorCode === "ECONNREFUSED" || lowerMessage.includes("econnrefused")) {
return {
failedPhase: RequestFailedPhase.TCPConnection,
errorCode: errorCode || "ECONNREFUSED",
errorDescription:
"Connection refused. The server actively refused the connection. This usually means no service is listening on the specified port, or a firewall is blocking the connection.",
rawErrorMessage,
};
}
// Connection reset
if (
errorCode === "ECONNRESET" ||
lowerMessage.includes("econnreset") ||
lowerMessage.includes("connection reset")
) {
return {
failedPhase: RequestFailedPhase.TCPConnection,
errorCode: errorCode || "ECONNRESET",
errorDescription:
"Connection reset. The connection was forcibly closed by the server or a network device. This can happen due to server restarts, load balancer timeouts, or network issues.",
rawErrorMessage,
};
}
// Connection aborted
if (
errorCode === "ECONNABORTED" ||
lowerMessage.includes("econnaborted") ||
lowerMessage.includes("connection aborted")
) {
return {
failedPhase: RequestFailedPhase.RequestAborted,
errorCode: errorCode || "ECONNABORTED",
errorDescription:
"Connection aborted. The request was aborted, possibly due to a timeout or the connection being closed unexpectedly.",
rawErrorMessage,
};
}
// Timeout errors
if (
errorCode === "ETIMEDOUT" ||
errorCode === "ESOCKETTIMEDOUT" ||
lowerMessage.includes("timeout") ||
lowerMessage.includes("exceeded")
) {
return {
failedPhase: RequestFailedPhase.RequestTimeout,
errorCode: errorCode || "TIMEOUT",
errorDescription:
"Request timed out. The server did not respond within the allowed time. This could be due to network latency, server overload, or the server being unresponsive.",
rawErrorMessage,
};
}
// SSL/TLS Certificate errors
if (
lowerMessage.includes("certificate has expired") ||
lowerMessage.includes("cert_has_expired")
) {
return {
failedPhase: RequestFailedPhase.CertificateError,
errorCode: "CERT_HAS_EXPIRED",
errorDescription:
"SSL certificate has expired. The server's SSL certificate is no longer valid. The certificate needs to be renewed.",
rawErrorMessage,
};
}
if (
lowerMessage.includes("self-signed certificate") ||
lowerMessage.includes("self signed certificate") ||
lowerMessage.includes("depth_zero_self_signed_cert")
) {
return {
failedPhase: RequestFailedPhase.CertificateError,
errorCode: "SELF_SIGNED_CERT",
errorDescription:
"Self-signed certificate detected. The server is using a self-signed SSL certificate that is not trusted by default. Consider using a certificate from a trusted Certificate Authority.",
rawErrorMessage,
};
}
if (
lowerMessage.includes("certificate signed by unknown authority") ||
lowerMessage.includes("unable_to_verify_leaf_signature") ||
lowerMessage.includes("unable to verify")
) {
return {
failedPhase: RequestFailedPhase.CertificateError,
errorCode: "CERT_UNKNOWN_AUTHORITY",
errorDescription:
"SSL certificate signed by unknown authority. The certificate chain could not be verified against known Certificate Authorities.",
rawErrorMessage,
};
}
if (
lowerMessage.includes("ssl") ||
lowerMessage.includes("tls") ||
lowerMessage.includes("certificate") ||
lowerMessage.includes("handshake")
) {
return {
failedPhase: RequestFailedPhase.TLSHandshake,
errorCode: errorCode || "TLS_ERROR",
errorDescription:
"TLS/SSL handshake failed. There was an error establishing a secure connection to the server. This could be due to certificate issues, protocol mismatches, or the server not supporting HTTPS.",
rawErrorMessage,
};
}
// Network errors
if (lowerMessage.includes("network error") || errorCode === "ERR_NETWORK") {
return {
failedPhase: RequestFailedPhase.NetworkError,
errorCode: errorCode || "NETWORK_ERROR",
errorDescription:
"Network error occurred. Unable to reach the server due to network connectivity issues. Please check your network connection and ensure the server is accessible.",
rawErrorMessage,
};
}
// Host unreachable
if (
errorCode === "EHOSTUNREACH" ||
lowerMessage.includes("host unreachable")
) {
return {
failedPhase: RequestFailedPhase.TCPConnection,
errorCode: errorCode || "EHOSTUNREACH",
errorDescription:
"Host unreachable. The network path to the server could not be found. This may be due to routing issues or the host being offline.",
rawErrorMessage,
};
}
// Network unreachable
if (
errorCode === "ENETUNREACH" ||
lowerMessage.includes("network unreachable")
) {
return {
failedPhase: RequestFailedPhase.TCPConnection,
errorCode: errorCode || "ENETUNREACH",
errorDescription:
"Network unreachable. There is no route to the network where the server resides. This is typically a routing or connectivity issue.",
rawErrorMessage,
};
}
// Server responded with error status
if (axiosError?.response) {
const status: number = axiosError.response.status;
let description: string = `Server responded with HTTP status ${status}.`;
if (status >= 500) {
description += " This indicates a server-side error.";
} else if (status === 404) {
description += " The requested resource was not found.";
} else if (status === 403) {
description += " Access to the resource is forbidden.";
} else if (status === 401) {
description += " Authentication is required or has failed.";
} else if (status === 400) {
description += " The request was malformed or invalid.";
} else if (status >= 400) {
description += " This indicates a client-side error.";
}
return {
failedPhase: RequestFailedPhase.ServerResponse,
errorCode: `HTTP_${status}`,
errorDescription: description,
rawErrorMessage,
};
}
// Request was made but no response received
if (axiosError?.request && !axiosError?.response) {
return {
failedPhase: RequestFailedPhase.NetworkError,
errorCode: errorCode || "NO_RESPONSE",
errorDescription:
"No response received from the server. The request was sent but no response was returned. This could indicate the server is down, unreachable, or the request timed out.",
rawErrorMessage,
};
}
// Default/Unknown error
return {
failedPhase: RequestFailedPhase.Unknown,
errorCode: errorCode,
errorDescription: `Request failed: ${API.getFriendlyErrorMessage(error as Error)}`,
rawErrorMessage,
};
}
}