Files
web/packages/trpc/lib/errors.ts
Joakim Jäderberg 99537b13e8 Merged in chore/add-error-details-for-sentry (pull request #3378)
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
2026-01-12 09:01:44 +00:00

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}]`
)
}