-
Notifications
You must be signed in to change notification settings - Fork 0
Improve error handling of responses #821
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| export type ErrorsBody = { | ||
| errors: Array<AnyError>; | ||
| }; | ||
|
|
||
| export type AnyError = | ||
| 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
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wanted to distinguish between:
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; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<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; | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| } 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")) { | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
? Since this isn't a subclass of Error this feels a bit nicer to me.