From 60970d08b39f1c6e846bfcf958a4de51fd5ccbff Mon Sep 17 00:00:00 2001 From: Robbe Van Petegem Date: Sun, 12 Jul 2026 13:12:16 +0200 Subject: [PATCH 1/4] Improve error handling of responses --- src/errors.ts | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/http.ts | 71 +++++++++++++++++++++++++++++++++----- src/index.ts | 1 + 3 files changed, 158 insertions(+), 9 deletions(-) create mode 100644 src/errors.ts diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 00000000..5ff1164f --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,95 @@ +export type ErrorsBody = { + errors: Array; +}; + +export type AnyError = + ValidationErrorMessage | PermissionErrorMessage | NotFoundErrorMessage; + +export type ValidationErrorMessage = { + attribute: string; + type: "not_unique" | "required" | "wrong_credentials" | "incorrect_password"; +}; + +export type PermissionErrorMessage = { + policy: string; + type: "unauthorized" | "forbidden"; +}; + +export type NotFoundErrorMessage = { + model: string; + type: "not_found"; +}; + +// NOTE: This message can also include a trace in a structured way, but we don't case about that +export type RailsErrorMessage = { + status: number; + error: string; + exception: string; +}; + +/** + * We create our own subclass of error, to hold the structured errors we get from the api + * + * Each subclass of this needs to set a message for compatibility with the general `Error`, + * though we probably would only use the `details` + */ +export class CustomError extends Error { + details: Array; + + constructor(message: string, details: Array) { + super(message); + this.name = "CustomError"; + this.details = details; + } +} + +export class UnauthorizedError extends CustomError { + constructor(details: Array) { + super("You are not signed in", details); + this.name = "UnauthorizedError"; + } +} + +// Our API returns a forbidden status when the user is not allowed to perform an action, or when they enter the wrong password +export class ForbiddenError extends CustomError< + PermissionErrorMessage | ValidationErrorMessage +> { + constructor(details: Array) { + super("You are not allowed to perform this action", details); + this.name = "ForbiddenError"; + } +} + +export class NotFoundError extends CustomError { + constructor(details: Array) { + super("Could not find object", details); + this.name = "NotFoundError"; + } +} + +export class UnprocessableContentError extends CustomError { + constructor(details: Array) { + super("Could not save object", details); + this.name = "UnprocessableContentError"; + } +} + +export class UnknownError extends Error { + details?: unknown; + + constructor(message: unknown, details?: unknown) { + super(`${message}`); + this.name = "UnknownError"; + this.details = details; + } +} + +export class UnexpectedError extends Error { + details: object; + + constructor(message: string, details: RailsErrorMessage) { + super(message); + this.name = "UnexpectedError"; + this.details = details; + } +} diff --git a/src/http.ts b/src/http.ts index 3f2eaa27..75812140 100644 --- a/src/http.ts +++ b/src/http.ts @@ -2,6 +2,20 @@ import useFetchRetry from "fetch-retry"; import { Scope } from "./scopes"; import { ApiToken } from "./types/auth"; import { RetryOptions } from "./types/fetch_retry"; +import { + CustomError, + ErrorsBody, + ForbiddenError, + NotFoundError, + NotFoundErrorMessage, + PermissionErrorMessage, + RailsErrorMessage, + UnauthorizedError, + UnexpectedError, + UnknownError, + UnprocessableContentError, + ValidationErrorMessage, +} from "./errors"; const fetchRetry = useFetchRetry(fetch, { retries: 0, @@ -111,22 +125,61 @@ export async function httpDelete( } async function resolve(request: Request): Promise { - let response: Response, result: ReturnType; + let response: Response; try { response = await fetchRetry(request); - result = response.status === 204 ? true : await response.json(); } catch (error) { - const reason: Record = {}; if (error instanceof Error) { - reason[error.constructor.name] = [error.message]; + throw error; } else { - reason["UnknownError"] = [`${error}`]; + throw new UnknownError(error); } - throw reason; } + + // Status 204 is a special case, since this doesn't have a body + if (response.status === 204) { + return true as ReturnType; + } + + // If the body isn't json, we throw an error with the body parsed as plain text + const contentType = response.headers.get("Content-Type"); + if (contentType !== "application/json") { + const body = await response.text(); + throw new UnknownError( + `Expected a JSON response but got ${contentType}`, + body, + ); + } + if (response.ok) { - return result; - } else { - throw result; + return await response.json(); + } + + const body: ErrorsBody = await response.json(); + + if (!Array.isArray(body.errors)) { + throw new UnexpectedError( + "Received an unhandled JSON error", + body as unknown as RailsErrorMessage, + ); + } + + // We throw a specific type of error, depending on the status - we know the type of message that can occur + // for each body, so we can tell the typescript compiler what we expect + switch (response.status) { + case 401: + throw new UnauthorizedError(body.errors as Array); + case 403: + throw new ForbiddenError( + body.errors as Array, + ); + case 404: + throw new NotFoundError(body.errors as Array); + case 422: + throw new UnprocessableContentError( + body.errors as Array, + ); + default: + throw new CustomError("Unexpected structured error", body.errors); } } diff --git a/src/index.ts b/src/index.ts index 06fd0a90..976b81b1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import { UserModule, } from "./api_module"; export * from "./api_module"; +export * from "./errors"; export * from "./scopes"; export * from "./types"; From 1776f8349ee6672177e372d1095bf15643db72fe Mon Sep 17 00:00:00 2001 From: Robbe Van Petegem Date: Sat, 25 Jul 2026 14:11:49 +0200 Subject: [PATCH 2/4] Fix contentType comparison --- src/http.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/http.ts b/src/http.ts index 75812140..aabfe608 100644 --- a/src/http.ts +++ b/src/http.ts @@ -143,7 +143,7 @@ async function resolve(request: Request): Promise { // If the body isn't json, we throw an error with the body parsed as plain text const contentType = response.headers.get("Content-Type"); - if (contentType !== "application/json") { + if (contentType === null || !contentType?.startsWith("application/json")) { const body = await response.text(); throw new UnknownError( `Expected a JSON response but got ${contentType}`, From c669df87cdbe505aec39b57f5799e62c5f993826 Mon Sep 17 00:00:00 2001 From: Robbe Van Petegem Date: Sat, 25 Jul 2026 14:18:00 +0200 Subject: [PATCH 3/4] Update types --- src/errors.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/errors.ts b/src/errors.ts index 5ff1164f..ec5ce17d 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -6,8 +6,9 @@ export type AnyError = ValidationErrorMessage | PermissionErrorMessage | NotFoundErrorMessage; export type ValidationErrorMessage = { + model: string; attribute: string; - type: "not_unique" | "required" | "wrong_credentials" | "incorrect_password"; + type: string; }; export type PermissionErrorMessage = { From c10fb43a2e6a255b5f01ccb00328ec684d1021a9 Mon Sep 17 00:00:00 2001 From: Robbe Van Petegem Date: Sat, 25 Jul 2026 14:18:29 +0200 Subject: [PATCH 4/4] Fix typo --- src/errors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/errors.ts b/src/errors.ts index ec5ce17d..c6c4d22d 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -21,7 +21,7 @@ export type NotFoundErrorMessage = { type: "not_found"; }; -// NOTE: This message can also include a trace in a structured way, but we don't case about that +// NOTE: This message can also include a trace in a structured way, but we don't care about that export type RailsErrorMessage = { status: number; error: string;