improvement(api): use HttpError base class for typed-error status mapping (#4746)

* improvement(api): use HttpError base class for typed-error status mapping

* chore(api): drop unreachable runtime check after HttpError instanceof guard
This commit is contained in:
Waleed
2026-05-26 14:54:19 -07:00
committed by GitHub
parent e66daa1b93
commit 77c1d24b17
4 changed files with 37 additions and 12 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
import { HttpError } from '@/lib/core/utils/http-error'
import { USER_FILE_ACCESSIBLE_PROPERTIES } from '@/lib/workflows/types'
import { normalizeName } from '@/executor/constants'
import {
@@ -30,7 +31,7 @@ export interface BlockReferenceResult {
blockId: string
}
export class InvalidFieldError extends Error {
export class InvalidFieldError extends HttpError {
readonly statusCode = 400
constructor(
+19
View File
@@ -0,0 +1,19 @@
/**
* Base class for domain errors that map to a specific HTTP status when they
* bubble up unhandled through `withRouteHandler`. Modeled after NestJS
* `HttpException` / Spring `ResponseStatusException`: subclasses declare a
* concrete `statusCode`, and the centralized route wrapper uses an
* `instanceof HttpError` check (not duck-typing on a `statusCode` property)
* to decide whether to forward the error's `message` to the client.
*
* Using a class check prevents third-party errors that happen to carry a
* `statusCode`-shaped field from being treated as typed HTTP errors and
* leaking internal details.
*
* Subclasses MUST ensure that `message` is safe to expose to clients — no
* stack traces, secrets, file paths, ORM internals, or upstream provider
* details.
*/
export abstract class HttpError extends Error {
abstract readonly statusCode: number
}
+14 -10
View File
@@ -2,6 +2,7 @@ import { createLogger, runWithRequestContext } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { HttpError } from '@/lib/core/utils/http-error'
import { generateRequestId } from '@/lib/core/utils/request'
const logger = createLogger('RouteHandler')
@@ -12,21 +13,24 @@ type RouteHandler<T = unknown> = (
) => Promise<NextResponse | Response> | NextResponse | Response
/**
* Reads a numeric `statusCode` (4xx or 5xx) off an Error so typed domain errors
* (e.g. `WorkspaceAccessDeniedError`, `InvalidFieldError`) map to the correct
* HTTP status when they bubble up unhandled instead of defaulting to 500.
* Reads a numeric `statusCode` (4xx or 5xx) off an `HttpError` so typed domain
* errors (e.g. `WorkspaceAccessDeniedError`, `InvalidFieldError`) map to the
* correct HTTP status when they bubble up unhandled instead of defaulting to
* 500.
*
* Uses an `instanceof HttpError` check (not duck-typing on `statusCode`) so
* third-party errors that happen to carry a `statusCode`-shaped field cannot
* trigger this path and leak their internal `message` to the client.
*
* When a typed status is returned, the error's `message` is sent to the client
* verbatim — matching the NestJS `HttpException` / Spring `ResponseStatusException`
* convention. The safety contract is convention-based: only attach `statusCode`
* to errors whose `message` is safe to expose to clients (no stack traces,
* secrets, file paths, ORM internals). Untyped errors fall back to a generic
* 500 response with no message exposure.
* convention. Subclasses of `HttpError` are responsible for keeping `message`
* safe to expose to clients (no stack traces, secrets, file paths, ORM
* internals).
*/
function readTypedErrorStatus(error: unknown): number | undefined {
if (!(error instanceof Error)) return undefined
const status = (error as { statusCode?: unknown }).statusCode
if (typeof status !== 'number') return undefined
if (!(error instanceof HttpError)) return undefined
const status = error.statusCode
if (status < 400 || status >= 600) return undefined
return status
}
+2 -1
View File
@@ -8,6 +8,7 @@ import {
workspace,
} from '@sim/db/schema'
import { and, eq, isNull } from 'drizzle-orm'
import { HttpError } from '@/lib/core/utils/http-error'
export type PermissionType = (typeof permissionTypeEnum.enumValues)[number]
export interface WorkspaceBasic {
@@ -153,7 +154,7 @@ export async function checkWorkspaceAccess(
* centralized route wrapper maps it to HTTP 403 instead of defaulting to 500.
* The `message` is intentionally client-safe and is exposed to API responses.
*/
export class WorkspaceAccessDeniedError extends Error {
export class WorkspaceAccessDeniedError extends HttpError {
readonly statusCode = 403
readonly workspaceId: string