Include more details when throwing errors for debugging in Sentry * WIP throw errors with more details for debugging in Sentry * Fix throwing response-data * Clearer message when a response fails * Add message to errors * better typings * . * Try to send profileID and membershipNumber to Sentry when we fail to parse the apiResponse * rename notFound -> notFoundError * Merge branch 'master' of bitbucket.org:scandic-swap/web into chore/add-error-details-for-sentry Approved-by: Linus Flood
265 lines
6.0 KiB
TypeScript
265 lines
6.0 KiB
TypeScript
import { TRPCError } from "@trpc/server"
|
|
|
|
import { getResponseBody } from "./utils/getResponseBody"
|
|
|
|
type CustomCause = {
|
|
message: string
|
|
errorDetails: Record<string, unknown>
|
|
}
|
|
|
|
type ResponseLike = {
|
|
status: number
|
|
statusText: string
|
|
body: string | Record<string, unknown>
|
|
url?: string
|
|
}
|
|
|
|
type TRPCCause = ResponseLike | CustomCause | Error | string
|
|
|
|
export function isCustomCause(cause: unknown): cause is CustomCause {
|
|
return (
|
|
!!cause &&
|
|
typeof cause === "object" &&
|
|
"message" in cause &&
|
|
"errorDetails" in cause &&
|
|
(cause as CustomCause).errorDetails !== undefined
|
|
)
|
|
}
|
|
|
|
export function isTRPCError(error: unknown): error is TRPCError {
|
|
return error instanceof Error && error.name === "TRPCError"
|
|
}
|
|
|
|
export function gatewayTimeout(cause?: TRPCCause, message: string = "") {
|
|
return new TRPCError({
|
|
code: "GATEWAY_TIMEOUT",
|
|
cause: harmonizeCause(cause, message),
|
|
})
|
|
}
|
|
|
|
export function unauthorizedError(cause?: TRPCCause, message: string = "") {
|
|
return new TRPCError({
|
|
code: "UNAUTHORIZED",
|
|
cause: harmonizeCause(cause, message),
|
|
})
|
|
}
|
|
|
|
export function forbiddenError(cause?: TRPCCause, message: string = "") {
|
|
return new TRPCError({
|
|
code: "FORBIDDEN",
|
|
cause: harmonizeCause(cause, message),
|
|
})
|
|
}
|
|
|
|
export function conflictError(cause?: TRPCCause, message: string = "") {
|
|
return new TRPCError({
|
|
code: "CONFLICT",
|
|
cause: harmonizeCause(cause, message),
|
|
})
|
|
}
|
|
|
|
export function badRequestError(cause?: TRPCCause, message: string = "") {
|
|
return new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
cause: harmonizeCause(cause, message),
|
|
})
|
|
}
|
|
|
|
export function notFoundError(cause?: TRPCCause, message: string = "") {
|
|
return new TRPCError({
|
|
code: "NOT_FOUND",
|
|
cause: harmonizeCause(cause, message),
|
|
})
|
|
}
|
|
|
|
export function unprocessableContent(cause?: TRPCCause, message: string = "") {
|
|
return new TRPCError({
|
|
code: "UNPROCESSABLE_CONTENT",
|
|
cause: harmonizeCause(cause, message),
|
|
})
|
|
}
|
|
|
|
export function internalServerError(cause?: TRPCCause, message: string = "") {
|
|
return new TRPCError({
|
|
code: "INTERNAL_SERVER_ERROR",
|
|
cause: harmonizeCause(cause, message),
|
|
})
|
|
}
|
|
|
|
export const SESSION_EXPIRED = "SESSION_EXPIRED"
|
|
export class SessionExpiredError extends Error {}
|
|
export function sessionExpiredError() {
|
|
return new TRPCError({
|
|
code: "UNAUTHORIZED",
|
|
message: SESSION_EXPIRED,
|
|
cause: new SessionExpiredError(SESSION_EXPIRED),
|
|
})
|
|
}
|
|
|
|
export const PUBLIC_UNAUTHORIZED = "PUBLIC_UNAUTHORIZED"
|
|
export class PublicUnauthorizedError extends Error {}
|
|
export function publicUnauthorizedError() {
|
|
return new TRPCError({
|
|
code: "UNAUTHORIZED",
|
|
message: PUBLIC_UNAUTHORIZED,
|
|
cause: new PublicUnauthorizedError(PUBLIC_UNAUTHORIZED),
|
|
})
|
|
}
|
|
|
|
export function httpStatusByErrorCode(error: TRPCError) {
|
|
switch (error.code) {
|
|
case "BAD_REQUEST":
|
|
return 400
|
|
case "UNAUTHORIZED":
|
|
return 401
|
|
case "FORBIDDEN":
|
|
return 403
|
|
case "NOT_FOUND":
|
|
return 404
|
|
case "CONFLICT":
|
|
return 409
|
|
case "UNPROCESSABLE_CONTENT":
|
|
return 422
|
|
case "GATEWAY_TIMEOUT":
|
|
return 504
|
|
case "INTERNAL_SERVER_ERROR":
|
|
default:
|
|
return 500
|
|
}
|
|
}
|
|
|
|
function errorCodeByHttpStatus(status: number): TRPCError["code"] {
|
|
switch (status) {
|
|
case 400:
|
|
return "BAD_REQUEST"
|
|
case 401:
|
|
return "UNAUTHORIZED"
|
|
case 403:
|
|
return "FORBIDDEN"
|
|
case 404:
|
|
return "NOT_FOUND"
|
|
case 409:
|
|
return "CONFLICT"
|
|
case 422:
|
|
return "UNPROCESSABLE_CONTENT"
|
|
case 504:
|
|
return "GATEWAY_TIMEOUT"
|
|
case 500:
|
|
default:
|
|
return "INTERNAL_SERVER_ERROR"
|
|
}
|
|
}
|
|
|
|
export function serverErrorByStatus(
|
|
status: number,
|
|
cause: CustomCause | Error | string
|
|
): TRPCError
|
|
export function serverErrorByStatus(
|
|
status: number,
|
|
cause: ResponseLike,
|
|
message: string
|
|
): TRPCError
|
|
export function serverErrorByStatus(
|
|
status: number,
|
|
cause?: TRPCCause,
|
|
message?: string
|
|
) {
|
|
switch (status) {
|
|
case 401:
|
|
return unauthorizedError(cause, message)
|
|
case 403:
|
|
return forbiddenError(cause, message)
|
|
case 404:
|
|
return notFoundError(cause, message)
|
|
case 409:
|
|
return conflictError(cause, message)
|
|
case 422:
|
|
return unprocessableContent(cause, message)
|
|
case 500:
|
|
return internalServerError(cause, message)
|
|
case 504:
|
|
return gatewayTimeout(cause, message)
|
|
default:
|
|
return internalServerError(cause, message)
|
|
}
|
|
}
|
|
|
|
function harmonizeCause(
|
|
cause: TRPCCause | undefined,
|
|
message: string = ""
|
|
): CustomCause | Error | undefined {
|
|
if (!cause) {
|
|
return undefined
|
|
}
|
|
|
|
if (isResponseLike(cause)) {
|
|
return {
|
|
message: message || `HTTP Error ${cause.status}: ${cause.statusText}`,
|
|
errorDetails: {
|
|
status: cause.status,
|
|
statusText: cause.statusText || errorCodeByHttpStatus(cause.status),
|
|
body: truncate(cause.body, 200), // Avoids issues in Sentry with large bodies
|
|
url: cause.url,
|
|
},
|
|
} satisfies CustomCause
|
|
}
|
|
|
|
if (typeof cause === "string") {
|
|
return { message: cause, errorDetails: {} } satisfies CustomCause
|
|
}
|
|
|
|
return cause
|
|
}
|
|
|
|
export async function extractResponseDetails(
|
|
response: Response
|
|
): Promise<ResponseLike> {
|
|
const body = await getResponseBody(response)
|
|
|
|
return {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
body,
|
|
url: response.url,
|
|
}
|
|
}
|
|
|
|
function isResponseLike(cause: TRPCCause): cause is ResponseLike {
|
|
if (typeof cause !== "object" || !cause) {
|
|
return false
|
|
}
|
|
|
|
if (!("status" in cause) || typeof cause.status !== "number") {
|
|
return false
|
|
}
|
|
|
|
if (!("statusText" in cause) || typeof cause.statusText !== "string") {
|
|
return false
|
|
}
|
|
|
|
if (!("body" in cause)) {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
function truncate(
|
|
str: string | Record<string, unknown>,
|
|
maxLength: number
|
|
): string {
|
|
if (typeof str !== "string") {
|
|
str = JSON.stringify(str)
|
|
}
|
|
|
|
if (str.length <= maxLength) {
|
|
return str
|
|
}
|
|
const originalLength = str.length
|
|
|
|
return (
|
|
str.slice(0, maxLength) +
|
|
`... [truncated, original length: ${originalLength}]`
|
|
)
|
|
}
|