Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
export type ErrorsBody = {
errors: Array<AnyError>;
};

export type AnyError =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export type AnyError =
export type AnyErrorMessage =

? Since this isn't a subclass of Error this feels a bit nicer to me.

ValidationErrorMessage | PermissionErrorMessage | NotFoundErrorMessage;

export type ValidationErrorMessage = {
model: string;
attribute: string;
type: string;
};

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 care 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<T = AnyError> extends Error {
details: Array<T>;

constructor(message: string, details: Array<T>) {
super(message);
this.name = "CustomError";
this.details = details;
}
}

export class UnauthorizedError extends CustomError<PermissionErrorMessage> {
constructor(details: Array<PermissionErrorMessage>) {
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<PermissionErrorMessage | ValidationErrorMessage>) {
super("You are not allowed to perform this action", details);
this.name = "ForbiddenError";
}
}

export class NotFoundError extends CustomError<NotFoundErrorMessage> {
constructor(details: Array<NotFoundErrorMessage>) {
super("Could not find object", details);
this.name = "NotFoundError";
}
}

export class UnprocessableContentError extends CustomError<ValidationErrorMessage> {
constructor(details: Array<ValidationErrorMessage>) {
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 {
Comment on lines +78 to +88

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to distinguish between:

  • The rails API returns an error that we didn't explicitly send (fe a 500 internal server error)
  • We get an error that we truly don't know what to do with (fe. getting a non-json body)

I don't think this is the best potential naming, but it's what I could come up with

details: object;

constructor(message: string, details: RailsErrorMessage) {
super(message);
this.name = "UnexpectedError";
this.details = details;
}
}
71 changes: 62 additions & 9 deletions src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -111,22 +125,61 @@ export async function httpDelete(
}

async function resolve<ReturnType>(request: Request): Promise<ReturnType> {
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<string, string[]> = {};
if (error instanceof Error) {
reason[error.constructor.name] = [error.message];
throw error;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the future, we might want to check the content of these errors and return custom types that wrap these so that it's easier to handle them in the client (for example with an OfflineError)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I did that at work in the recent past, it's quite nice to do this (since otherwise it's just a TypeError in most browsers).

} 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 === null || !contentType?.startsWith("application/json")) {

@robbevp robbevp Jul 25, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a naive way of checking this. Maybe we should properly parse this header and check the value?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eh, seems fine to me.

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<PermissionErrorMessage>);
case 403:
throw new ForbiddenError(
body.errors as Array<PermissionErrorMessage | ValidationErrorMessage>,
);
case 404:
throw new NotFoundError(body.errors as Array<NotFoundErrorMessage>);
case 422:
throw new UnprocessableContentError(
body.errors as Array<ValidationErrorMessage>,
);
default:
throw new CustomError("Unexpected structured error", body.errors);
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
UserModule,
} from "./api_module";
export * from "./api_module";
export * from "./errors";
export * from "./scopes";
export * from "./types";

Expand Down