feat(integration): improve integration test connection (#3005)
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import type { AnyRequestError } from "../request-error";
|
||||
import { RequestError } from "../request-error";
|
||||
import { ResponseError } from "../response-error";
|
||||
import { matchErrorCode } from "./fetch-http-error-handler";
|
||||
import { HttpErrorHandler } from "./http-error-handler";
|
||||
|
||||
export class AxiosHttpErrorHandler extends HttpErrorHandler {
|
||||
handleRequestError(error: unknown): AnyRequestError | undefined {
|
||||
if (!(error instanceof AxiosError)) return undefined;
|
||||
if (error.code === undefined) return undefined;
|
||||
|
||||
logger.debug("Received Axios request error", {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
});
|
||||
|
||||
const requestErrorInput = matchErrorCode(error.code);
|
||||
if (!requestErrorInput) return undefined;
|
||||
|
||||
return new RequestError(requestErrorInput, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
handleResponseError(error: unknown): ResponseError | undefined {
|
||||
if (!(error instanceof AxiosError)) return undefined;
|
||||
if (error.response === undefined) return undefined;
|
||||
|
||||
logger.debug("Received Axios response error", {
|
||||
status: error.response.status,
|
||||
url: error.response.config.url,
|
||||
message: error.message,
|
||||
});
|
||||
|
||||
return new ResponseError({
|
||||
status: error.response.status,
|
||||
url: error.response.config.url ?? "?",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { objectEntries } from "../../../object";
|
||||
import type { Modify } from "../../../types";
|
||||
import type { AnyRequestError, AnyRequestErrorInput, RequestErrorCode, RequestErrorReason } from "../request-error";
|
||||
import { RequestError, requestErrorMap } from "../request-error";
|
||||
import type { ResponseError } from "../response-error";
|
||||
import { HttpErrorHandler } from "./http-error-handler";
|
||||
|
||||
export class FetchHttpErrorHandler extends HttpErrorHandler {
|
||||
constructor(private type = "undici") {
|
||||
super();
|
||||
}
|
||||
|
||||
handleRequestError(error: unknown): AnyRequestError | undefined {
|
||||
if (!isTypeErrorWithCode(error)) return undefined;
|
||||
|
||||
logger.debug(`Received ${this.type} request error`, {
|
||||
code: error.cause.code,
|
||||
});
|
||||
|
||||
const result = matchErrorCode(error.cause.code);
|
||||
if (!result) return undefined;
|
||||
|
||||
return new RequestError(result, { cause: error });
|
||||
}
|
||||
|
||||
/**
|
||||
* Response errors do not exist for fetch as it does not throw errors for non successful responses.
|
||||
*/
|
||||
handleResponseError(_: unknown): ResponseError | undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
type TypeErrorWithCode = Modify<
|
||||
TypeError,
|
||||
{
|
||||
cause: Error & { code: string };
|
||||
}
|
||||
>;
|
||||
|
||||
const isTypeErrorWithCode = (error: unknown): error is TypeErrorWithCode => {
|
||||
return (
|
||||
error instanceof TypeError &&
|
||||
error.cause instanceof Error &&
|
||||
"code" in error.cause &&
|
||||
typeof error.cause.code === "string"
|
||||
);
|
||||
};
|
||||
|
||||
export const matchErrorCode = (code: string): AnyRequestErrorInput | undefined => {
|
||||
for (const [key, value] of objectEntries(requestErrorMap)) {
|
||||
const entries = Object.entries(value) as [string, string | string[]][];
|
||||
const found = entries.find(([_, entryCode]) =>
|
||||
typeof entryCode === "string" ? entryCode === code : entryCode.includes(code),
|
||||
);
|
||||
if (!found) continue;
|
||||
|
||||
return {
|
||||
type: key,
|
||||
reason: found[0] as RequestErrorReason<typeof key>,
|
||||
code: code as RequestErrorCode,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { AnyRequestError } from "../request-error";
|
||||
import type { ResponseError } from "../response-error";
|
||||
|
||||
export abstract class HttpErrorHandler {
|
||||
abstract handleRequestError(error: unknown): AnyRequestError | undefined;
|
||||
abstract handleResponseError(error: unknown): ResponseError | undefined;
|
||||
}
|
||||
5
packages/common/src/errors/http/handlers/index.ts
Normal file
5
packages/common/src/errors/http/handlers/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from "./http-error-handler";
|
||||
export * from "./fetch-http-error-handler";
|
||||
export * from "./ofetch-http-error-handler";
|
||||
export * from "./axios-http-error-handler";
|
||||
export * from "./tsdav-http-error-handler";
|
||||
@@ -0,0 +1,38 @@
|
||||
import { FetchError } from "ofetch";
|
||||
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import type { AnyRequestError } from "../request-error";
|
||||
import { ResponseError } from "../response-error";
|
||||
import { FetchHttpErrorHandler } from "./fetch-http-error-handler";
|
||||
import { HttpErrorHandler } from "./http-error-handler";
|
||||
|
||||
/**
|
||||
* Ofetch is a wrapper around the native fetch API
|
||||
* which will always throw the FetchError (also for non successful responses).
|
||||
*
|
||||
* It is for example used within the ctrl packages like qbittorrent, deluge, transmission, etc.
|
||||
*/
|
||||
export class OFetchHttpErrorHandler extends HttpErrorHandler {
|
||||
handleRequestError(error: unknown): AnyRequestError | undefined {
|
||||
if (!(error instanceof FetchError)) return undefined;
|
||||
if (!(error.cause instanceof TypeError)) return undefined;
|
||||
|
||||
const result = new FetchHttpErrorHandler("ofetch").handleRequestError(error.cause);
|
||||
if (!result) return undefined;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
handleResponseError(error: unknown): ResponseError | undefined {
|
||||
if (!(error instanceof FetchError)) return undefined;
|
||||
if (error.response === undefined) return undefined;
|
||||
|
||||
logger.debug("Received ofetch response error", {
|
||||
status: error.response.status,
|
||||
url: error.response.url,
|
||||
});
|
||||
|
||||
return new ResponseError(error.response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import type { AnyRequestError } from "../request-error";
|
||||
import { ResponseError } from "../response-error";
|
||||
import { FetchHttpErrorHandler } from "./fetch-http-error-handler";
|
||||
import { HttpErrorHandler } from "./http-error-handler";
|
||||
|
||||
export class TsdavHttpErrorHandler extends HttpErrorHandler {
|
||||
handleRequestError(error: unknown): AnyRequestError | undefined {
|
||||
return new FetchHttpErrorHandler("tsdav").handleRequestError(error);
|
||||
}
|
||||
|
||||
handleResponseError(error: unknown): ResponseError | undefined {
|
||||
if (!(error instanceof Error)) return undefined;
|
||||
// Tsdav sadly does not throw a custom error and rather just uses "Error"
|
||||
// https://github.com/natelindev/tsdav/blob/bf33f04b1884694d685ee6f2b43fe9354b12d167/src/account.ts#L86
|
||||
if (error.message !== "Invalid credentials") return undefined;
|
||||
|
||||
logger.debug("Received Tsdav response error", {
|
||||
status: 401,
|
||||
});
|
||||
|
||||
return new ResponseError({ status: 401, url: "?" });
|
||||
}
|
||||
}
|
||||
3
packages/common/src/errors/http/index.ts
Normal file
3
packages/common/src/errors/http/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./handlers";
|
||||
export * from "./request-error";
|
||||
export * from "./response-error";
|
||||
73
packages/common/src/errors/http/request-error.ts
Normal file
73
packages/common/src/errors/http/request-error.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export type AnyRequestError = {
|
||||
[key in keyof RequestErrorMap]: RequestError<key>;
|
||||
}[keyof RequestErrorMap];
|
||||
|
||||
export type AnyRequestErrorInput = {
|
||||
[key in RequestErrorType]: RequestErrorInput<key>;
|
||||
}[RequestErrorType];
|
||||
|
||||
export interface RequestErrorInput<TType extends RequestErrorType> {
|
||||
type: TType;
|
||||
reason: RequestErrorReason<TType>;
|
||||
code: RequestErrorCode;
|
||||
}
|
||||
|
||||
export class RequestError<TType extends RequestErrorType> extends Error {
|
||||
public readonly type: TType;
|
||||
public readonly reason: RequestErrorReason<TType>;
|
||||
public readonly code: RequestErrorCode;
|
||||
|
||||
constructor(input: AnyRequestErrorInput, options: { cause?: Error }) {
|
||||
super("Request failed", options);
|
||||
this.name = RequestError.name;
|
||||
|
||||
this.type = input.type as TType;
|
||||
this.reason = input.reason as RequestErrorReason<TType>;
|
||||
this.code = input.code;
|
||||
}
|
||||
|
||||
get cause(): Error | undefined {
|
||||
return super.cause as Error | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export const requestErrorMap = {
|
||||
certificate: {
|
||||
expired: ["CERT_HAS_EXPIRED"],
|
||||
hostnameMismatch: ["ERR_TLS_CERT_ALTNAME_INVALID", "CERT_COMMON_NAME_INVALID"],
|
||||
notYetValid: ["CERT_NOT_YET_VALID"],
|
||||
untrusted: ["DEPTH_ZERO_SELF_SIGNED_CERT", "UNABLE_TO_VERIFY_LEAF_SIGNATURE", "UNABLE_TO_GET_ISSUER_CERT_LOCALLY"],
|
||||
},
|
||||
connection: {
|
||||
hostUnreachable: "EHOSTUNREACH",
|
||||
networkUnreachable: "ENETUNREACH",
|
||||
refused: "ECONNREFUSED",
|
||||
reset: "ECONNRESET",
|
||||
},
|
||||
dns: {
|
||||
notFound: "ENOTFOUND",
|
||||
timeout: "EAI_AGAIN",
|
||||
noAnswer: "ENODATA",
|
||||
},
|
||||
timeout: {
|
||||
aborted: "ECONNABORTED",
|
||||
timeout: "ETIMEDOUT",
|
||||
},
|
||||
} as const satisfies Record<string, Record<string, string | string[]>>;
|
||||
|
||||
type RequestErrorMap = typeof requestErrorMap;
|
||||
|
||||
export type RequestErrorType = keyof RequestErrorMap;
|
||||
|
||||
export type RequestErrorReason<TType extends RequestErrorType> = keyof RequestErrorMap[TType];
|
||||
export type AnyRequestErrorReason = {
|
||||
[key in keyof RequestErrorMap]: RequestErrorReason<key>;
|
||||
}[keyof RequestErrorMap];
|
||||
|
||||
type ExtractInnerValues<T> = {
|
||||
[K in keyof T]: T[K][keyof T[K]];
|
||||
}[keyof T];
|
||||
|
||||
type FlattenStringOrStringArray<T> = T extends (infer U)[] ? U : T;
|
||||
|
||||
export type RequestErrorCode = FlattenStringOrStringArray<ExtractInnerValues<typeof requestErrorMap>>;
|
||||
12
packages/common/src/errors/http/response-error.ts
Normal file
12
packages/common/src/errors/http/response-error.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export class ResponseError extends Error {
|
||||
public readonly statusCode: number;
|
||||
public readonly url?: string;
|
||||
|
||||
constructor(response: { status: number; url?: string }, options?: ErrorOptions) {
|
||||
super("Response did not indicate success", options);
|
||||
this.name = ResponseError.name;
|
||||
|
||||
this.statusCode = response.status;
|
||||
this.url = response.url;
|
||||
}
|
||||
}
|
||||
2
packages/common/src/errors/index.ts
Normal file
2
packages/common/src/errors/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./parse";
|
||||
export * from "./http";
|
||||
3
packages/common/src/errors/parse/handlers/index.ts
Normal file
3
packages/common/src/errors/parse/handlers/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./parse-error-handler";
|
||||
export * from "./zod-parse-error-handler";
|
||||
export * from "./json-parse-error-handler";
|
||||
@@ -0,0 +1,16 @@
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { ParseError } from "../parse-error";
|
||||
import { ParseErrorHandler } from "./parse-error-handler";
|
||||
|
||||
export class JsonParseErrorHandler extends ParseErrorHandler {
|
||||
handleParseError(error: unknown): ParseError | undefined {
|
||||
if (!(error instanceof SyntaxError)) return undefined;
|
||||
|
||||
logger.debug("Received JSON parse error", {
|
||||
message: error.message,
|
||||
});
|
||||
|
||||
return new ParseError("Failed to parse json", { cause: error });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ParseError } from "../parse-error";
|
||||
|
||||
export abstract class ParseErrorHandler {
|
||||
abstract handleParseError(error: unknown): ParseError | undefined;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ZodError } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { ParseError } from "../parse-error";
|
||||
import { ParseErrorHandler } from "./parse-error-handler";
|
||||
|
||||
export class ZodParseErrorHandler extends ParseErrorHandler {
|
||||
handleParseError(error: unknown): ParseError | undefined {
|
||||
if (!(error instanceof ZodError)) return undefined;
|
||||
|
||||
// TODO: migrate to zod v4 prettfyError once it's released
|
||||
// https://v4.zod.dev/v4#error-pretty-printing
|
||||
const message = fromError(error, {
|
||||
issueSeparator: "\n",
|
||||
prefix: null,
|
||||
}).toString();
|
||||
|
||||
logger.debug("Received Zod parse error");
|
||||
|
||||
return new ParseError(message, { cause: error });
|
||||
}
|
||||
}
|
||||
2
packages/common/src/errors/parse/index.ts
Normal file
2
packages/common/src/errors/parse/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./handlers";
|
||||
export * from "./parse-error";
|
||||
10
packages/common/src/errors/parse/parse-error.ts
Normal file
10
packages/common/src/errors/parse/parse-error.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export class ParseError extends Error {
|
||||
constructor(message: string, options?: { cause: Error }) {
|
||||
super(`Failed to parse data:\n${message}`, options);
|
||||
this.name = ParseError.name;
|
||||
}
|
||||
|
||||
get cause(): Error {
|
||||
return super.cause as Error;
|
||||
}
|
||||
}
|
||||
3
packages/common/src/function.ts
Normal file
3
packages/common/src/function.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const isFunction = (value: unknown): value is (...args: unknown[]) => unknown => {
|
||||
return typeof value === "function";
|
||||
};
|
||||
@@ -10,3 +10,4 @@ export * from "./number";
|
||||
export * from "./error";
|
||||
export * from "./fetch-with-timeout";
|
||||
export * from "./theme";
|
||||
export * from "./function";
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from "./security";
|
||||
export * from "./encryption";
|
||||
export * from "./user-agent";
|
||||
export * from "./fetch-agent";
|
||||
export * from "./errors";
|
||||
|
||||
Reference in New Issue
Block a user