diff --git a/src/utils/errors/http-error.ts b/src/utils/errors/http-error.ts new file mode 100644 index 0000000..8627f8a --- /dev/null +++ b/src/utils/errors/http-error.ts @@ -0,0 +1,86 @@ +import { HttpResponse } from "@/utils/net"; +import { SoftError } from "./soft-error"; + +/** + * Represents an HTTP error. + */ +export class HttpError extends SoftError { + /** + * The HTTP Response object associated with this error. + */ + private readonly _response: HttpResponse; + + /** + * Creates a new {@link HttpError} instance. + * + * @param response - The HTTP Response object associated with this error. + * @param message - The error message. + * @param isSoft - Indicates whether the error is recoverable or not. + */ + constructor(response: HttpResponse, message?: string, isSoft?: boolean) { + super(isSoft ?? isServerError(response), message); + + this.name = "HttpError"; + this._response = response; + } + + /** + * Gets the HTTP Response object associated with this error. + */ + get response(): HttpResponse { + return this._response; + } + + /** + * Extracts error information from the given HTTP Response object + * and returns an {@link HttpError} instance. + * + * @param response - The HTTP Response object to extract the error information from. + * @param isSoft - Indicates whether the error is recoverable or not. + * + * @returns A `Promise` that resolves to an {@link HttpError} instance. + */ + static async fromResponse(response: HttpResponse, isSoft?: boolean): Promise { + const cachedResponse = HttpResponse.cache(response); + const errorText = `${response.status} (${ + await cachedResponse.text() + .then(x => x && !isHtmlDocument(x) ? `${response.statusText}, ${x}` : response.statusText) + .catch(() => response.statusText) + })`; + + return new HttpError(cachedResponse, errorText, isSoft); + } +} + +/** + * Determines if the given error is an {@link HttpError}. + * + * @param error - The error to be checked. + * + * @returns `true` if the provided error is an instance of HttpError; otherwise, `false`. + */ +export function isHttpError(error: unknown): error is HttpError { + return error instanceof HttpError; +} + +/** + * Determines whether the given `HttpResponse` represents a server error. + * + * @param response - The `HttpResponse` to check. + * + * @returns `true` if the response is a server error; otherwise, `false`. + */ +function isServerError(response: HttpResponse): boolean { + return response && (response.status === 429 || response.status >= 500); +} + +/** + * Determines if the given text is an HTML document. + * + * @param text - The string to check. + * + * @returns `true` if the provided string is an HTML document; otherwise, `false`. + */ +function isHtmlDocument(text: string): boolean { + return text.startsWith(" { + describe("constructor", () => { + test("initializes with given response and message", () => { + const response = HttpResponse.text("Resource does not exist", { status: 404, statusText: "Not Found" }); + + const error = new HttpError(response, "An error occurred.", false); + + expect(error).toBeInstanceOf(HttpError); + expect(error.name).toBe("HttpError") + expect(error.message).toBe("An error occurred."); + expect(error.isSoft).toBe(false); + expect(error.response).toBe(response); + }); + + test("initializes with isSoft set to true for server error", () => { + const response = HttpResponse.text("Somebody knows what happened?", { status: 500, statusText: "Internal Server Error" }); + + const error = new HttpError(response); + + expect(error.isSoft).toBe(true); + expect(error.response).toBe(response); + }); + + test("initializes with isSoft set to false for client error", () => { + const response = HttpResponse.text("It's not my fault!", { status: 400, statusText: "Bad Request" }); + + const error = new HttpError(response); + + expect(error.isSoft).toBe(false); + expect(error.response).toBe(response); + }); + }); + + describe("fromResponse", () => { + test("creates HttpError from given response", async () => { + const response = HttpResponse.text("Resource does not exist", { status: 404, statusText: "Not Found" }); + + const error = await HttpError.fromResponse(response, false); + + expect(error).toBeInstanceOf(HttpError); + expect(error.name).toBe("HttpError") + }); + + test("includes text content in the error message", async () => { + const response = HttpResponse.json({ error: "Resource does not exist" }, { status: 404, statusText: "Not Found" }); + + const error = await HttpError.fromResponse(response, false); + + expect(error.message).toBe(`404 (Not Found, ${JSON.stringify({ error: "Resource does not exist" })})`); + }); + + test("does not include HTML content in the error message", async () => { + const htmlContent = "Not FoundPage Not Found"; + const response = HttpResponse.text(htmlContent, { status: 404, statusText: "Not Found" }); + + const error = await HttpError.fromResponse(response); + + expect(error.message).toBe("404 (Not Found)"); + }); + + test("returns soft error for server error codes", async () => { + const response = HttpResponse.text("Somebody knows what happened?", { status: 500, statusText: "Internal Server Error" }); + + const error = await HttpError.fromResponse(response); + + expect(error.isSoft).toBe(true); + }); + + test("returns soft error for Too Many Requests error code (429)", async () => { + const response = HttpResponse.text("", { status: 429, statusText: "Too Many Requests" }); + + const error = await HttpError.fromResponse(response); + + expect(error.isSoft).toBe(true); + }); + + test("returns non-soft error for client error codes", async () => { + const response = HttpResponse.text("It's not my fault!", { status: 400, statusText: "Bad Request" }); + + const error = await HttpError.fromResponse(response); + + expect(error.isSoft).toBe(false); + }); + + test("can still read the response contents after the error is created", async () => { + const response = HttpResponse.json({ error: "Resource does not exist" }, { status: 404, statusText: "Not Found" }); + + const error = await HttpError.fromResponse(response); + const responseJson = await error.response.json(); + + expect(error.message).toBe(`404 (Not Found, ${JSON.stringify({ error: "Resource does not exist" })})`); + expect(responseJson).toEqual({ error: "Resource does not exist" }); + }); + }); +}); + +describe("isHttpError", () => { + test("returns true for HttpError", () => { + const response = HttpResponse.text("Resource does not exist", { status: 404, statusText: "Not Found" }); + + const error = new HttpError(response, "An error occurred.", false); + + expect(isHttpError(error)).toBe(true); + }); + + test("returns false for non-HttpError errors", () => { + expect(isHttpError(new Error("An error occurred."))).toBe(false); + }); + + test("returns false for non-error values", () => { + expect(isHttpError("An error occurred.")).toBe(false); + }); +});